A freelancing platform for college — lecturers publish real projects, students apply alone or with a party they recruit, and get paid and certified for finishing them.
Getting started · Deployment · Roadmap · Legacy version
PHive started in 2020 as a Laravel 7 + Vue 2 monolith. This repository is the rewrite: two independently deployable apps that talk over a JSON API.
phive/
├── backend/ Laravel 13 · PHP 8.3+ · PostgreSQL · Sanctum · Filament 5 · Octane
├── frontend/ Next.js 16 · React 19 · TypeScript · Tailwind 4
├── docs/ Deployment guide and assets
└── docker-compose.yml
There are two front doors: the Next.js app that students and lecturers use, and a
Filament admin panel served by Laravel itself at /admin.
The original code is preserved on the legacy
branch, untouched.
The rewrite reproduces the original's design rather than replacing it — the port was measured against the running legacy site, not eyeballed. Left is Laravel 7 + Vue 2; right is Laravel 13 + Next.js 16.
| 2020 · Laravel 7 + Vue 2 | Now · Laravel 13 + Next.js 16 | |
|---|---|---|
| Home | ||
| Explore | ||
| Leaderboard | ||
| Sign in | ||
| Register |
Every pair was captured the same way — 1425px wide, whole page, signed out — so the two
columns are directly comparable. See docs/screenshots/.
The frontend is edge-deployable (Cloudflare Workers by default) while the API needs PHP and a database. Splitting them lets each go where it runs best, and makes the API usable by anything else you want to build later.
Sanctum personal access tokens, not stateful cookies — so the two apps never need to share a root domain.
Browser ──▶ Next.js server action ──▶ POST /api/login ──▶ Laravel
│ │
│◀──────────── { user, token } ──────────┘
▼
httpOnly cookie (phive_token)
│
Browser ──▶ Next.js Server Component ──▶ Authorization: Bearer ──▶ Laravel
The token lives in an httpOnly cookie and is only ever read on the server, so client-side
JavaScript can never touch it. requireUser() in src/lib/dal.ts verifies it against the
API and is called by every protected page — authorisation lives next to the data, not in
a routing layer that can be bypassed.
There is deliberately no proxy.ts. Next 16 runs Proxy on the Node.js runtime and
forbids opting into edge, which the Cloudflare adapter cannot deploy. It was only doing
an optimistic cookie check anyway, so removing it cost nothing and unblocked Workers.
The httpOnly cookie is a browser concern. A native app talks to the same API the same
way: POST /api/login returns a token, and every later request sends
Authorization: Bearer <token>. Nothing needs adding for that.
JWT was considered and deliberately not used. Its real advantage is stateless
verification across several services, which a single API does not need — and it would
cost the two things this app relies on today: revoking one device's token on logout,
and signing every device out when a password changes. Both are one indexed delete
against personal_access_tokens; with JWTs they would need a denylist, which puts the
database lookup back and loses the only benefit.
Requirements: PHP 8.3+, Composer 2, Node 24+, pnpm 10+, PostgreSQL 14+.
git clone https://github.com/ivqonsanada/phive.git
cd phivecd backend
composer install
php artisan phive:install # env file, app key, database, migrations, demo data
composer dev # serve + queue worker + log tail on :8000phive:install asks which database to use and writes the credentials into .env for
you. Non-interactively (CI, containers):
php artisan phive:install --no-interaction-defaults --seedSeeded accounts — password password for all three:
| Role | Where | |
|---|---|---|
lecturer@phive.test |
Lecturer | Frontend |
student@phive.test |
Student | Frontend |
admin@phive.test |
Admin | /admin panel |
Google and Apple, via Socialite. Both are optional — without credentials the buttons are hidden and the routes return 503.
The OAuth round trip is a browser redirect flow, so those two routes live on the web
routes rather than under /api, where they have a session to carry state across the
hop. The callback finishes by redirecting to the frontend with a single-use code
valid for two minutes, never the token: URLs end up in browser history, server logs
and Referer headers. The frontend's server swaps that code for the token and puts it
straight into the httpOnly cookie.
Signing in with a provider whose email already exists links to that account instead of creating a duplicate. New accounts are created verified, without a password, and as students — choosing "lecturer" still has to satisfy the academic email rule, otherwise the whole restriction could be sidestepped by signing up through Google.
Set SOCIAL_PROVIDERS=google,apple on the frontend to match whatever the API has
credentials for.
DEMO_MODE=true enables php artisan phive:demo-reset, which drops every table,
reseeds, and clears uploads — and schedules it nightly. Without the flag the command
refuses to run and the schedule is never registered, because the whole point of a
destructive command is that it must be impossible to trigger by accident.
Filament 5, at http://localhost:8000/admin. Access is gated on an is_admin
flag rather than the platform role — being a lecturer says what you do on the
platform, not that you may administer it. The panel is the one session-based
surface in an otherwise token-authenticated API, so it runs on the web guard
explicitly; a bearer token is not a way in.
For a real deployment, seed your own administrator instead of the demo one:
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' \
php artisan db:seed --class=AdminSeedercd frontend
pnpm install
cp .env.example .env.local # API_URL=http://localhost:8000
pnpm dev # :3000cp backend/.env.example backend/.env
docker compose up --build| Task | Backend | Frontend |
|---|---|---|
| Dev server | composer dev |
pnpm dev |
| Dev on Octane | composer dev:octane |
— |
| Tests | php artisan test |
pnpm test |
| Lint / format | composer lint (Pint) |
pnpm lint |
| Types | — | pnpm typecheck |
| Production build | — | pnpm build |
Next 16 renamed middleware.ts to proxy.ts and made cookies() async. frontend/AGENTS.md
points at the version-accurate docs bundled in node_modules/next/dist/docs/ — read those
rather than relying on older tutorials.
Base URL /api. Authenticated routes expect Authorization: Bearer <token>.
Reads — these work for guests, but personalise themselves when a token is present
(for example is_wished only appears for a signed-in student):
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/home |
Project counts, top of each board, latest projects |
GET |
/projects |
Explore + search: ?query=, ?expertise=, ?status=, ?open_only=, ?page= |
GET |
/projects/{project_url} |
Detail, with skills, requirements, team, review |
GET |
/projects/{project_url}/similar |
Three related projects, matched to your expertise |
GET |
/users/{tagname} |
Public profile plus that user's projects |
GET |
/leaderboards |
One ranked board per expertise |
Auth and writes:
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
POST |
/register |
— | Create an account, get a token |
POST |
/login |
— | Exchange credentials for a token |
GET |
/auth/{provider}/redirect |
— | Start Google/Apple sign-in (not under /api) |
GET |
/auth/{provider}/callback |
— | Provider returns here (not under /api) |
POST |
/auth/exchange |
— | Swap the single-use code for a token |
POST |
/logout |
✓ | Revoke the calling token only |
GET |
/user |
✓ | The signed-in user |
PATCH |
/settings/password |
✓ | Change password, keep this device |
POST |
/password/email |
— | Send a reset link |
POST |
/password/reset |
— | Consume a reset token |
GET |
/email/verify/{id}/{hash} |
— | Signed verification link |
POST |
/email/resend |
✓ | Resend the verification email |
GET |
/wishlist |
✓ | Projects the student starred |
POST |
/projects/{project_url}/wishlist |
✓ | Toggle a project on the wishlist |
PATCH |
/settings/profile |
✓ | Partial profile update, plus skills |
POST |
/settings/avatar · /settings/cv |
✓ | Upload (multipart file) |
DELETE |
/settings/avatar · /settings/cv |
✓ | Remove |
POST |
/settings/experiences |
✓ | Add a CV entry |
PATCH DELETE |
/settings/experiences/{id} |
✓ | Edit or remove your own entry |
GET |
/party |
✓ | The party you lead, and ones you're in |
POST |
/users/{tagname}/invite/party |
✓ | Invite a student to your party |
DELETE |
/party/members/{tagname} |
✓ | Remove a member |
DELETE |
/party/{team}/leave |
✓ | Leave a party you don't lead |
POST |
/projects/{project_url}/apply/individual |
✓ | Apply on your own |
POST |
/projects/{project_url}/apply/team |
✓ | Apply with your party |
DELETE |
/projects/{project_url}/apply |
✓ | Withdraw your application |
GET |
/inbox |
✓ | Invitations and messages |
POST |
/inbox/{id}/respond |
✓ | Accept or decline (accept: bool) |
POST |
/inbox/{id}/read |
✓ | Mark as read |
GET |
/project-box |
✓ | Everything you're involved in |
POST |
/project-box/{box}/confirm |
✓ | Take or decline a shortlisted seat |
GET |
/messages |
✓ | Your conversations |
GET POST |
/messages/{tagname} |
✓ | Read or add to a thread |
Lecturer project management — all require a lecturer token, and a policy scopes
every one of them to that lecturer's own projects. They live under /my so none of
them collide with the public {project_url} routes:
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/my/projects |
Own projects, drafts included |
POST |
/my/projects |
Create — publish: false saves a draft |
PATCH |
/my/projects/{project_url} |
Edit; publish: true also publishes |
POST |
/my/projects/{project_url}/publish |
Publish an existing draft as-is |
POST |
/my/projects/{project_url}/close |
Stop accepting applications |
DELETE |
/my/projects/{project_url} |
Withdraw (blocked while ongoing) |
POST |
/my/projects/{project_url}/thumbnail |
Upload a cover image (multipart file) |
DELETE |
/my/projects/{project_url}/thumbnail |
Remove the cover image |
GET POST |
/my/projects/{project_url}/shortlist |
See applicants, and choose who goes through |
POST |
/my/projects/{project_url}/start |
Start with whoever confirmed |
GET POST |
/my/projects/{project_url}/review |
Close out, score participants, award points |
POST |
/my/projects/{project_url}/invite/{tagname} |
Invite a student directly |
Resource wrapping is off, so a resource is returned at the top level. Paginated
collections keep Laravel's { data, links, meta } envelope.
Lecturer sign-ups are restricted to non-student academic addresses. That rule is
Indonesian-university-specific and lives in config/phive.php — change
lecturer_email_pattern, or set it to null to accept anything.
The data model is fully ported (24 tables, typed Eloquent models, enums for every status field). Progress so far:
- Auth: register, login, logout, email verification, password reset and change
- Social sign-in with Google and Apple
- Projects: explore, search, filter by expertise, detail, similar projects
- Wishlist: star from anywhere, review it on its own page
- Profiles: public view — finished work for students, published projects for lecturers
- Leaderboard and home page stats
- Publishing: draft, edit, publish, close applications, withdraw
- Publishing extras: thumbnail upload
- Profile editing: avatar and CV upload, skills, experiences
- Inviting students to a project directly
- Party recruitment and the inbox invitation flow
- Applying: as an individual or with your party
- Project box: shortlist, confirm, start, review and leaderboard points
- Inbox and direct messaging, delivered live over WebSockets
- Newcomer walkthrough, change password, project dashboard and the 404 page
Every screen the original had is ported. Three deliberate differences:
- Settings is one page. The original split it between a two-step
/profile/editwizard and a/settingsthat held only a password form. Both live here under Settings. - No public Wishlist tab. The original showed anyone's saved projects on their profile. This API scopes a wishlist to its owner, which is the better default, so the tab is not reproduced.
- No
/profileroute. Your own profile is the same public page everyone else sees, at/u/<handle>, with the wishlist on/wishlist.
Direct messages broadcast on the recipient's private channel over Reverb, which replaces the old Pusher setup. Laravel Echo subscribes in the browser and appends incoming messages to an open thread without a reload.
Channel authorisation is proxied. The Sanctum token lives in an httpOnly cookie the
browser cannot read, so Echo authorises against /api/broadcasting/auth on the
Next.js side, which forwards to Laravel with the bearer token attached — the token
never reaches client JavaScript.
Realtime is additive: leave NEXT_PUBLIC_REVERB_APP_KEY empty and the app behaves
exactly as before, reading threads on request.
cd backend && php artisan reverb:start # or `composer dev`, which runs it for youMIT — see LICENSE.