StageFlow — Internship Lifecycle Platform

LaravelPHPMySQLBladeTailwind CSS· 3 min read

A platform that models an internship as a single lifecycle rather than a pile of paperwork: a company publishes an offer, a student applies, uploads the required documents, and ends with a scheduled defense that carries a jury and a grade — all as linked records instead of scattered files.

The problem & constraints

Internship administration usually lives in three places at once — a spreadsheet of who applied where, an inbox of attached reports, and a separate calendar of defense slots. Nothing reconciles them, so the real state of a student's file is whatever the last person to touch it remembers. The constraint was to represent the whole path in one schema, with each stage a record that can be queried, not an email thread.

Approach

  • Five linked entities carry the domain: companies own internships, internships receive applications, applications lead to defenses, and documents attach to the user who uploaded them.
  • Applications hold an explicit status enum (pending / accepted / rejected) rather than an inferred state, so "where is this file" always has a single answer in the database.
  • Role is chosen and validated at registration (student / professor / admin), which lets the same routes serve different views without a separate account system per audience.
  • Resource controllers for the things you browse (internships, companies) and dedicated endpoints for the state transitions (/apply, /upload, /defense/schedule) — reads and moves through the lifecycle are deliberately different shapes.
1..n1..n1..n0..1companiesusersrole: student,professor, admininternshipsdocumentsPOST /uploadowner: userapplicationsPOST /applystatus: pending,accepted, rejecteddefensesPOST /defense/schedulejury, grade

Described in full:

  1. companies → internships (1..n)
  2. users — role: student, — professor, admin → documents — POST /upload — owner: user (1..n)
  3. internships → applications — POST /apply — status: pending, — accepted, rejected (1..n)
  4. applications — POST /apply — status: pending, — accepted, rejected → defenses — POST /defense/schedule — jury, grade (0..1)
The five linked records, and the endpoints that move a file between them. Cardinalities sit on the arrows; the route that creates a record is written inside it.

Trade-offs made

Decision

A defense as its own table keyed on the application, not columns on the application: keeps schedule, jury, and grade out of the application row and lets a file legitimately exist without a defense yet, at the cost of one more join on the views that show a complete student record.

Decision

Role as a validated field on the user rather than a permissions package: the fastest honest path to three clearly-separated audiences, accepting that anything finer-grained than "student / professor / admin" would need to be rebuilt on a real authorization layer.

Results

A working path from a published internship offer through to a graded defense, where each step is a persisted record with an owner — the question "which students still owe documents" becomes a query rather than an audit of someone's inbox.

What I'd do differently

Store jury members as a real relation instead of a comma-separated string in a single column — the current shape can't answer "which defenses is this professor sitting on" without string matching. I'd also validate uploaded documents by MIME type and not just by size, since a 2 MB cap alone doesn't say anything about what's actually in the file.