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
- Multi-campus by design. A file owned by the Cantilan campus has different default visibility than a file owned by the main campus. The system has to know about campus as a first-class concept, not as a tag.
- Mixed network conditions. Some campuses have decent connectivity, some have what a Friday afternoon can do to a shared link. Uploads have to survive flaky connections and partial failures.
- Two audiences, two surfaces. Faculty and admin need a full workspace UI. Students need a focused, narrow surface — submit assignments, view feedback, see grades. Same backend, different fronts.
- Run on the university's own infrastructure. Education data, in-country servers.
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) │
└─────────────┘ └─────────────────┘ └───────────────────┘
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.
- Local FS is fast, latency-free, and lives on the same host as the app. Recently-uploaded files, in-flight submissions, and anything written or read in the last day sit here.
- S3 is the durable, geographically-shared layer. Anything older than a configurable threshold gets migrated asynchronously by a queue worker. Cross-campus retrieval and disaster recovery both lean on it.
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.
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
- One platform shared across every NEMSU campus.
- Files, folders, and submissions with per-owner visibility rules.
- Team workspaces for departments, research groups, class sections, and admin committees.
- Resumable uploads via a Go tus service that survives flaky campus networks and exam-week traffic.
- Every upload scanned by ClamAV before becoming visible.
- Official documents signed in Go with embedded X.509 signatures, verifiable in any standard PDF reader.
- Two-tier storage: hot local FS, cold and shared Amazon S3.
- Google Sheets and Docs edited in place, with platform-controlled access synced to Google Workspace.
- Google Sign-In as primary auth path for
@nemsu.edu.phaccounts. - Audit trail of file views, downloads, and submission activity.
- Live in production on the university's own infrastructure.
What I'd do differently next time
- Search as a real subsystem. The current path leans on the database. Indexed search via something like Meilisearch would have been worth the operational cost given how heavily search ended up being used.
- Mobile app for submissions. Students were doing more submissions from their phones than I planned for. A native wrapper with reliable background upload would have made a common case feel less fragile on flaky networks.
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.