back to portfolio
// Case Study 04  ·  university platform

NEMSU Data Hub — one drive across every campus

A Google Drive-style platform for NEMSU — the North Eastern Mindanao State University system. The brief in one sentence: stop scattering files across campuses, inboxes, and USB sticks, and give every department and student team a single place to work together.

Role
Engineer — build + ongoing maintenance
Stack
Laravel, Vue.js (Inertia.js), MySQL, Go (tus upload service + PDF signer), ClamAV, S3 + local FS, Google Workspace (Sheets, Docs, OAuth Sign-In), Tailwind CSS
Status
udh.nemsu.edu.ph — live, in daily use

The starting line

NEMSU runs across multiple campuses. Cantilan, Tagbina, Bislig, the main campus — each one has its own admin office, its own faculty, and its own way of moving paperwork around. The shared layer between them was, in practice, email attachments and the occasional USB stick brought to a meeting.

Submissions from students to instructors lived in inboxes. Department reports for the central office lived in inboxes. Memos from administration to faculty lived in inboxes. Every time someone needed last quarter's file, they searched for it in their own inbox, and if they couldn't find it, asked the author to resend.

The university wanted what every organization eventually wants: one place where the file lives, with the right people able to see it. A Google Drive that knew about campuses, departments, faculty, and student teams.

Constraints

Architecture in one picture

                              ┌──────────────────────────┐
                              │     udh.nemsu.edu.ph     │
                              └────────────┬─────────────┘
                                           │
                                  ┌────────▼────────┐
                                  │      Nginx      │  TLS, routing
                                  └────────┬────────┘
                  ┌────────────────────────┴────────────────────────┐
                  │                                                 │
        ┌─────────▼──────────┐                          ┌───────────▼───────────┐
        │  Laravel + Inertia │                          │   Go upload service   │
        │  - Auth + AuthZ    │                          │   - tus protocol      │
        │  - File metadata   │◀── signed handoff ───────│   - resumable chunks  │
        │  - Submissions     │                          │   - high concurrency  │
        │  - Audit log       │                          └────────────┬──────────┘
        └──┬─────────┬───────┘                                       │
           │         │                                               │
           │         │              ┌────────────────────┐           │
           │         │              │  ClamAV scanner    │◀──────────┤
           │         │              │  (quarantine bad)  │           │
           │         │              └────────────────────┘           │
           │         │                                               │
           │         │              ┌────────────────────┐           │
           │         ├─────────────▶│  Go PDF signer     │           │
           │         │              │  (X.509 signature) │           │
           │         │              └────────────────────┘           │
           │         │                                               │
           │         │              ┌────────────────────┐           │
           │         └─────────────▶│  Google Workspace  │           │
           │                        │  (Sheets, Docs,    │           │
           │                        │   OAuth Sign-In)   │           │
           │                        └────────────────────┘           │
           │                                                         │
    ┌──────▼──────┐                  ┌─────────────────┐   ┌─────────▼─────────┐
    │    MySQL    │                  │   Amazon S3     │   │     Local FS      │
    │  (records)  │                  │ (cold + shared) │   │  (hot + recent)   │
    └─────────────┘                  └─────────────────┘   └───────────────────┘
// multi-campus platform with go upload + scan + sign sidecars

The headline picture is a Laravel monolith. The honest picture has three sidecars: a Go upload service, a ClamAV scanner, and a Go PDF signer. Each one exists because PHP was the wrong tool for a specific job and pretending otherwise would have hurt the platform.

Modelling a university as a graph

Every file in the system belongs to one of four owners: a campus, a department, a team, or a person. Everything else — visibility, sharing, inheritance — falls out of getting those four right.

A user belongs to a campus. They may belong to one or more departments inside that campus. They may belong to one or more teams (a research group, a class section, an admin committee). When a file is uploaded, the upload chooses an owner. The owner decides the file's default visibility, and the visibility decides who can find it in search.

// app/Policies/FilePolicy.php
public function view(User $user, File $file): bool
{
    return match ($file->owner_type) {
        OwnerType::CAMPUS     => $user->campus_id === $file->owner_id,
        OwnerType::DEPARTMENT => $user->belongsToDepartment($file->owner_id),
        OwnerType::TEAM       => $user->belongsToTeam($file->owner_id),
        OwnerType::PERSON     => $user->id === $file->owner_id
                                  || $file->sharedWith($user),
    };
}

Sharing layers on top. A file with a personal owner can be explicitly shared with a team. A team file can be promoted to the department. A campus-owned policy file can be readable by every user in the system, but only editable by campus admins. The graph fans out from the four owner types and stays comprehensible.

Submissions as first-class objects

The hard part wasn't the file storage. The hard part was submissions — the recurring student-to-instructor workflow that had been bouncing through email forever.

A submission is not just a file. It's a request, a deadline, a list of accepted file types, a list of expected submitters, and a feedback loop. The instructor creates a submission slot for a team or a class section. Students see it in their dashboard with a countdown. They upload. The instructor reviews, leaves a comment, and either accepts or returns it for revision. The student sees the result on the same screen.

Modeling it explicitly meant we could do things email could never do cleanly: show every student whether their classmates have submitted (without revealing the submission contents), auto-close at the deadline, and regenerate a clean folder of all submissions for the instructor with one click at the end.

Resumable uploads: Go + tus, not PHP

The first upload pipeline ran through PHP. It worked — until exam week. Hundreds of students submitting on the same afternoon, dozens of faculty syncing folders in the background, and PHP-FPM spent more time juggling long-lived multipart requests than serving the actual app. PHP is great at short request/response cycles. It is not great at hundreds of concurrent streaming uploads.

So I broke uploads off into a Go service speaking the tus protocol — the open standard for resumable file uploads. The Laravel app still owns authorization and metadata. When a user wants to upload, Laravel hands the client a signed tus URL with an expiry. The client uploads to the Go service in chunks. If the connection drops at chunk 7 of 12, the next request resumes at chunk 8 instead of starting over.

// Laravel hands off to Go upload service
public function authorizeUpload(Request $r)
{
    $this->authorize('uploadTo', [File::class, $r->input('folder_id')]);

    $token = JWT::encode([
        'sub'        => $r->user()->id,
        'folder_id'  => $r->input('folder_id'),
        'max_bytes'  => $r->user()->quotaRemaining(),
        'exp'        => now()->addMinutes(15)->timestamp,
    ], config('tus.secret'));

    return [
        'endpoint' => config('tus.endpoint'),  // Go service
        'token'    => $token,
    ];
}

The Go service runs one goroutine per active upload, eats hundreds of concurrent streams comfortably on the same VPS, and never blocks the PHP workers. After exam week stopped feeling like exam week, the architecture earned its place.

Antivirus: every upload through ClamAV

The platform accepts files from thousands of student accounts. Treating them as trusted by default is a bad plan. I wired ClamAV into the post-upload pipeline so every file gets scanned before it becomes visible in the system.

When the Go upload service finishes assembling a file, it notifies Laravel. Laravel queues a scan job. The worker streams the file to a clamd daemon over a Unix socket and waits for the verdict. Clean files flip to status: ready. Infected files flip to status: quarantined, the blob is moved out of the user-visible path, and an alert lands in the admin channel with the signature name.

// app/Jobs/ScanUpload.php
public function handle(ClamAvClient $clam): void
{
    $verdict = $clam->scanStream($this->file->readStream());

    if ($verdict->infected) {
        $this->file->quarantine($verdict->signature);
        AdminAlert::dispatch("ClamAV: {$verdict->signature} on {$this->file->id}");
        return;
    }

    $this->file->markReady();
}

One scan per upload, no exceptions. False positives are rare and easy to release manually from the admin surface. The cost of running clamd is small compared to the cost of an institution-wide platform serving its first piece of malware.

PDF signing in Go: digital signatures on official documents

Universities generate official documents constantly — certificates, transcripts, memos, signed approvals. A PDF downloaded from the platform needs to be verifiable as authentic, not just visually convincing. That means real cryptographic signatures embedded into the PDF itself, not a watermark.

PHP's PDF signing story is workable but slow and fiddly. Go's ecosystem here is cleaner. I wrote a small Go signer service that holds the institutional signing key, accepts a PDF and a context payload, embeds an X.509 signature using the PKCS#7 detached profile, and returns the signed bytes. The Go service is the only process that touches the private key — Laravel never sees it.

// Laravel calls Go signer when a document is finalized
$signed = Http::withToken(config('signer.token'))
    ->attach('pdf', $unsignedPdf, 'document.pdf')
    ->post(config('signer.endpoint'), [
        'subject' => $document->title,
        'signed_by_id' => $approver->id,
        'reason' => 'NEMSU official document',
    ])->body();

$document->storeSigned($signed);

Validators in Adobe Reader and the OS-level PDF viewers confirm the signature against the institution's certificate chain. The signed PDF carries its own provenance even after it leaves the platform.

Hot vs cold: S3 and local FS, both

Storage isn't one thing. I run a two-tier blob layer: local file system for hot data and shared Amazon S3 for cold + cross-campus access.

When a file is requested, Laravel checks the local cache first and falls back to S3. Reads from S3 warm the local cache so the next read is fast. The user never has to know which tier the bytes came from.

// Lesson Authorization rules, upload reliability, virus scanning, signed PDFs, and tiered storage are where a university platform earns its keep. Picking the right language for each one of those jobs — PHP for the app, Go for the long-lived and security-sensitive bits — was the difference between a portal that survived exam week and one that didn't.

Editing Google Sheets and Docs in place

A lot of the university's living documents — class lists, grading rubrics, departmental rosters, committee minutes — already lived in Google Sheets and Google Docs. The first version of the platform let users upload a static export, which everyone hated. Faculty wanted to keep editing the real spreadsheet, not re-upload it after every change.

So I integrated the platform directly with Google Workspace. A user signs in once with Google OAuth, the platform stores a long-lived refresh token, and from then on the user can create, view, and edit Google Sheets and Docs from inside the Data Hub. The file in the platform is a first-class record; the Google file is the source of truth for content.

// app/Services/GoogleWorkspace.php
public function openEditor(User $user, File $file): string
{
    $client = $this->clientFor($user); // refreshes token if needed

    return match ($file->google_type) {
        'sheet' => "https://docs.google.com/spreadsheets/d/{$file->google_id}/edit",
        'doc'   => "https://docs.google.com/document/d/{$file->google_id}/edit",
    };
}

Permissions on the Google side are synced from the platform: when a user gets added to a department, their Workspace account is automatically granted edit access to that department's shared Sheets and Docs. Removing them from the department revokes it. One source of truth for access, two surfaces.

Google Sign-In doubles as the platform's primary auth path. Most users already had an @nemsu.edu.ph Workspace account — making them create a second password was friction nobody asked for. The platform accepts Google identities, maps them to local user records, and falls back to email + password only for accounts that don't have a Workspace identity.

What broke, or surprised me

Departments don't map cleanly to org charts

The first version assumed each department had one clear parent. Reality: cross-listed departments, joint programs, and one campus where a single instructor was attached to three departments at once. I rewrote the membership model from "user has one department" to "user has many department memberships, each with a role." Slight schema change, much less hand-editing.

Search was the second-most-used feature

The folder tree did most of the work but search was a close second. Faculty using the system long enough stopped navigating to files and just typed names. I tuned the search path to do filename, owner, and content-aware indexing on common document types so the second-place feature wouldn't feel second-class.

What it does today

What I'd do differently next time

What I take away

Universities are sprawling. The temptation is always to rebuild Google Drive. The actual work is figuring out which of the university's own concepts — campus, department, team, submission — deserve to be first-class in the schema. The platform is only as useful as the model underneath it.

Need a platform that fits how your organization actually works? I take on full-stack Laravel + Vue builds end-to-end.
Start a conversation