A multi-campus state university moved its files around by email attachment and USB stick. It wanted one place where every file lives, with the right people able to see it, and student submissions that stop bouncing through inboxes.
A shared file platform for a whole institution has to get four things right: who can see what, uploads that survive bad networks and exam-week traffic, files from untrusted accounts, and documents that must be provably authentic. This post shows how I modeled ownership and visibility in Laravel, and why resumable uploads and PDF signing went to small Go services, with generic examples you can adapt.
Background
Where the university started
The university runs across multiple campuses. 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 a satellite 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.
Two terms used below
- Sidecar service. A small, separate process that does one job for the main application, such as receiving uploads, while the main app keeps ownership of users, permissions and data.
- tus. An open HTTP protocol for resumable uploads. The client sends a file in pieces and, after a dropped connection, asks the server how much arrived and continues from there.
The shape of the system
The headline picture is a Laravel + Inertia monolith that owns authentication, authorization, file metadata, submissions, and the audit log. The honest picture has three sidecars: a Go upload service, a malware 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.
Records live in MySQL. File contents live in two tiers, local disk for hot data and object storage for cold and shared data, covered further down.
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. Checking access becomes one question per owner type: is this user part of the campus, the department, the team, or are they the person (or someone the file was shared with)?
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 checks that they may write to the target folder, then hands the client a short-lived signed upload token that carries the destination and the user's remaining quota. 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.
The Go service runs one goroutine per active upload, eats hundreds of concurrent streams comfortably on the same server, and never blocks the PHP workers. After exam week stopped feeling like exam week, the architecture earned its place.
Malware scanning on every upload
The platform accepts files from a large population of student accounts. Treating them as trusted by default is a bad plan. I wired malware scanning 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, which queues a scan job. A file stays hidden until the scan comes back. Clean files become visible. Infected files are quarantined, moved out of the user-visible path, and administrators are alerted.
One scan per upload, no exceptions. False positives are rare and easy for an administrator to release. The cost of running a scanner 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 accepts a PDF and a context payload, embeds an X.509 signature using the PKCS#7 detached profile, and returns the signed bytes. When a document is finalized, Laravel sends it to the signer and stores what comes back. The Go service is the only process that touches the private key, Laravel never sees it.
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 documents in the browser with ONLYOFFICE
A lot of the university's living documents, class lists, grading rubrics, departmental rosters, committee minutes, are office files that change every week. The first version of the platform only let users upload and download them, which everyone hated. Faculty wanted to keep editing the real file, not download it, edit locally, and re-upload after every change.
So the platform embeds ONLYOFFICE. Opening a document or spreadsheet in the platform launches the ONLYOFFICE editor in the browser, against the file the platform already stores. When an editing session ends, ONLYOFFICE hands the saved file back to the platform, which stores it as the file's new content. The file in the platform stays the single source of truth, and the platform's access rules decide who can open the editor at all.
Single sign-on doubles as the platform's primary auth path. Most users already had an institutional account, making them create a second password was friction nobody asked for. The platform accepts those identities over OAuth and maps them to local user records.
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 instructors attached to several 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.
Design decisions
The interface follows the model: who you are decides which surface you get and what you can find.
-
Two audiences, two surfaces
Faculty and admin get the full workspace. Students get a narrow one: submit assignments, view feedback, see grades. Same backend, different fronts.
-
Submissions you can see coming
Students see each submission slot with a countdown, whether classmates have submitted (never what they submitted), and the instructor's verdict on the same screen.
-
Search treated as a main path
Long-time users stopped browsing folders and typed names, so search covers filename, owner and document content instead of feeling second-class.
-
Fewer steps to the real file
ONLYOFFICE opens documents in the browser against the stored file, and single sign-on means no second password for anyone with an institutional account.
Practical examples
Simplified example. The snippets below are generic Laravel and Go code showing the techniques from this post, with neutral names. They are not the production code or schema.
1. Decide access from the owner type
class DocumentPolicy
{
public function view(User $user, Document $document): bool
{
// Explicit shares always grant access.
if ($document->shares()->where('user_id', $user->getKey())->exists()) {
return true;
}
// Otherwise: one question per owner type.
return match ($document->owner_type) {
'campus' => $user->campus_id === $document->owner_id,
'department' => $user->departments()->whereKey($document->owner_id)->exists(),
'team' => $user->teams()->whereKey($document->owner_id)->exists(),
'person' => $user->getKey() === $document->owner_id,
default => false,
};
}
}
2. Accept resumable uploads in Go with tusd
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"os"
"github.com/tus/tusd/v2/pkg/filestore"
tusd "github.com/tus/tusd/v2/pkg/handler"
)
var secret = []byte(os.Getenv("UPLOAD_TOKEN_SECRET"))
// validToken checks an HMAC-SHA256 signature issued by the main app.
func validToken(payload, signature string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func main() {
store := filestore.New("./uploads")
composer := tusd.NewStoreComposer()
store.UseIn(composer)
handler, err := tusd.NewHandler(tusd.Config{
BasePath: "/files/",
StoreComposer: composer,
NotifyCompleteUploads: true,
// Reject the upload unless the main app signed the token.
PreUploadCreateCallback: func(hook tusd.HookEvent) (tusd.HTTPResponse, tusd.FileInfoChanges, error) {
payload := hook.HTTPRequest.Header.Get("Upload-Token")
signature := hook.HTTPRequest.Header.Get("Upload-Signature")
if !validToken(payload, signature) {
return tusd.HTTPResponse{}, tusd.FileInfoChanges{},
tusd.NewError("ERR_INVALID_TOKEN", "invalid upload token", http.StatusUnauthorized)
}
return tusd.HTTPResponse{}, tusd.FileInfoChanges{}, nil
},
})
if err != nil {
log.Fatalf("unable to create handler: %s", err)
}
// Each finished upload is reported here; tell the main app to queue a scan.
go func() {
for event := range handler.CompleteUploads {
log.Printf("upload %s finished (%d bytes)", event.Upload.ID, event.Upload.Size)
}
}()
http.Handle("/files/", http.StripPrefix("/files/", handler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Expected behaviour: a client whose connection drops sends a
HEAD request, reads the Upload-Offset
header, and continues from that byte instead of starting over.
The Go HTTP server handles each request on its own goroutine, so
hundreds of slow uploads do not block each other.
3. Scan every upload before it becomes visible
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class ScanUpload implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public Document $document) {}
public function handle(): void
{
$path = Storage::disk('local')->path($this->document->path);
// clamdscan exit codes: 0 = clean, 1 = virus found, 2 = error.
$result = Process::run(['clamdscan', '--no-summary', '--fdpass', $path]);
match ($result->exitCode()) {
0 => $this->document->update(['status' => 'clean']),
1 => $this->quarantine(),
default => throw new RuntimeException('Scan failed: '.$result->errorOutput()),
};
}
private function quarantine(): void
{
$target = 'quarantine/'.basename($this->document->path);
Storage::disk('local')->move($this->document->path, $target);
$this->document->update(['status' => 'quarantined', 'path' => $target]);
event(new UploadQuarantined($this->document));
}
}
// Every listing only shows scanned, clean files.
Document::where('status', 'clean')->latest()->paginate(50);
Expected output when testing with the standard EICAR test file:
$ clamdscan --no-summary /tmp/eicar.com.txt
/tmp/eicar.com.txt: Win.Test.EICAR_HDB-1 FOUND
$ echo $?
1
4. Read from hot storage, fall back to S3
public function stream(Document $document)
{
Gate::authorize('view', $document);
$local = Storage::disk('local');
// Cold file: copy it back from S3 so the next read is fast.
if (! $local->exists($document->path)) {
$local->writeStream($document->path, Storage::disk('s3')->readStream($document->path));
}
return $local->response($document->path);
}
Common mistakes
- Treating campus or department as a tag instead of a first-class owner in the schema.
- Assuming each user has exactly one department.
- Pushing hundreds of long-lived streaming uploads through PHP-FPM.
- Making a file visible before its malware scan has finished.
- Letting the web application hold the PDF signing key.
- Treating a watermark as proof that a document is authentic.
- Making users download, edit and re-upload documents that change every week.
Best practices
- Keep authorization and metadata in the main app; give sidecars a short-lived signed token that carries the destination and quota.
- Scan every upload, with no exceptions. Hide it until it is clean; quarantine and alert when it is not.
- Isolate the private signing key in one small service that only signs.
- Keep recent files on local disk, move older ones to object storage, and warm the local cache on reads.
- If users already have institutional accounts, use single sign-on instead of a second password.
- Treat search as a main path from day one.
Conclusion
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.
The takeaway: model your organization's real owners first, keep the main app in charge of authorization, and hand each long-lived or security-sensitive job to a tool that is good at it. A good next step is to list the owner types in your own domain and write down the single access question each one answers.
What it does today
- One platform shared across all of the university's campuses.
- 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 for malware 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.
- Documents and spreadsheets edited in the browser with ONLYOFFICE, saved back to the platform.
- Single sign-on with institutional accounts as the primary auth path.
- Audit trail of file views, downloads, and submission activity.
- Deploys onto the university's own on-prem 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.
References and further reading
- tus: resumable upload protocol
- tusd, the reference tus server in Go (GitHub)
- Go documentation
- Go standard library: crypto/x509
- ClamAV documentation
- Laravel documentation: Authorization
- Laravel documentation: Queues
- Laravel documentation: Processes
- Laravel documentation: File storage
- Inertia.js documentation
- Vue.js guide
- ONLYOFFICE API documentation
- Amazon S3 documentation
- OAuth 2.0
- Meilisearch documentation
- OWASP File Upload Cheat Sheet
- Related on this site: How to enforce attorney-client privilege in a Laravel case management app
- Related on this site: How to digitize paper personnel records with Laravel and move a team from FTP to Git