A Laravel 12 REST API for managing events and attendee registrations. Users authenticate with Sanctum bearer tokens, create events (name, description, start/end time), and register as attendees; a console command emails reminders for events starting within the next 24 hours. JSON-only — the sole web page is an API landing page mapping every endpoint.
New developer? Start with
.docs/tldr.md— every doc summarised on one page. The full guide lives in.docs/. The complete API reference isopenapi.yaml(OpenAPI 3.0 — kept honest by a route-coverage test).
| Tool | Version | Installed by |
|---|---|---|
| PowerShell + winget | Windows 10/11 stock | — (the only true prerequisites) |
| Git | any recent | setup.ps1 |
| Node.js | LTS | setup.ps1 |
| PHP | 8.4 (repo requires ^8.2) |
setup.ps1 (zip from php.net into %LOCALAPPDATA%\Programs\php-8.4) |
| Composer | 2.x | setup.ps1 (phar + wrapper next to PHP) |
| uv + Python | latest | setup.ps1 (runs the .claude tooling) |
| just | any recent | setup.ps1 |
| Claude Code CLI | latest | setup.ps1 (optional, for AI-assisted dev) |
# 1. One-time machine setup (idempotent — safe to re-run)
pwsh ./setup.ps1
# 2. Close and reopen PowerShell so PATH updates land
# 3. One-time app bootstrap: composer + npm + Vite build + .env + sqlite + migrate
just bootstrap
# 4. Seed demo data — 1000 users / 200 events (takes a couple of minutes)
just fresh
# 5. Start the dev server
just startThe app is now at http://127.0.0.1:8109. Stop it with just stop.
Every /api endpoint except POST /api/login requires a Sanctum bearer token —
log in as any seeded user (password password), then send the token. See the
API quick tour below.
Full endpoint/param/response reference: openapi.yaml. Real
request/response pairs against seeded data (just fresh):
POST /api/login
Content-Type: application/json
Accept: application/json
{"email": "dolly72@example.com", "password": "password"}{"token": "1|kJqtI91WstSiRvHndrtQqA5KlzDonnfMwhMIKDiUce8d6e4c"}Any seeded user works — every seeded password is literally password.
GET /api/events
Accept: application/json
Authorization: Bearer 1|kJqtI91WstSiRvHndrtQqA5KlzDonnfMwhMIKDiUce8d6e4c{
"data": [
{
"id": 1,
"name": "Est consectetur est nihil.",
"start_time": "2026-08-09 10:12:26",
"end_time": "2026-09-28 05:35:16",
"description": "Incidunt consectetur incidunt labore quae et et sit. …",
"user_id": 119
}
],
"links": {
"first": "http://127.0.0.1:8109/api/events?page=1",
"last": "http://127.0.0.1:8109/api/events?page=14",
"prev": null,
"next": "http://127.0.0.1:8109/api/events?page=2"
},
"meta": {"current_page": 1, "per_page": 15, "total": 200}
}(data trimmed to one of 15 items; meta abridged.) Without the token the same
request returns 401 {"message":"Unauthenticated."}.
GET /api/events?include=user{
"data": [
{
"id": 1,
"name": "Est consectetur est nihil.",
"start_time": "2026-08-09 10:12:26",
"end_time": "2026-09-28 05:35:16",
"description": "Incidunt consectetur incidunt labore quae et et sit. …",
"user_id": 119,
"user": {
"id": 119,
"name": "Ola Rosenbaum",
"email": "aurore17@example.com",
"email_verified_at": "2026-08-02T02:30:53.000000Z",
"created_at": "2026-08-02T02:30:54.000000Z",
"updated_at": "2026-08-02T02:30:54.000000Z"
}
}
]
}Events accept include=user,attendees; attendee endpoints accept
include=user,event. Ownership rules: anyone authenticated can create events and
register attendance; update and delete of an event are owner-only (403 for
everyone else, enforced by EventPolicy).
php artisan app:send-event-reminders finds every event starting within the next
24 hours and queues an EventReminderNotification (mail) for each attendee.
With the default .env (QUEUE_CONNECTION=database, MAIL_MAILER=log) the
notifications sit in the jobs table until you run
php artisan queue:work --stop-when-empty, and the mail lands in
storage/logs/laravel.log.
The command is scheduled — routes/console.php registers it
daily (midnight, cron 0 0 * * *):
Schedule::command(SendEventReminders::class)->daily();A schedule worker still has to be running for it to fire. Locally, activate it with:
just schedule # php artisan schedule:work — keeps running until Ctrl+C
just schedule-list # show every scheduled command + next due timeOn a server, use a single cron entry instead: * * * * * php artisan schedule:run.
Without one of those, the schedule is registered but nothing triggers it.
Run just with no arguments to list every recipe. The ones you'll use daily:
| Command | What it does |
|---|---|
just bootstrap |
One-time app setup: deps, .env, sqlite db, migrate, asset build |
just start |
Serve at http://127.0.0.1:8109 in a background window |
just serve |
Serve in the foreground (Ctrl+C to stop) |
just stop |
Stop only THIS repo's php artisan serve |
just migrate |
Run pending migrations |
just fresh |
Drop + re-migrate + seed demo data (IRREVERSIBLE locally) |
just schedule |
Run the scheduler in the foreground (activates the daily reminder job) |
just schedule-list |
List scheduled commands with their cron expression + next due time |
just test |
PHPUnit suite (just test --filter=X passes through) |
just lint / just lint-fix |
Laravel Pint style check / auto-fix |
just claudex |
Launch Claude Code (Sonnet, all permissions) |
just test # whole suite
just test --filter=EventApiTest # one classPHPUnit 11 via php artisan test. phpunit.xml overrides the database to sqlite
:memory:, so tests never touch your seeded database/database.sqlite. What's
covered:
| Test | Guards |
|---|---|
tests/Feature/AuthTest |
login issues a usable bearer token; bad credentials 422; logout revokes tokens |
tests/Feature/EventApiTest |
401 without a token; full CRUD happy path; update/delete of another user's event is 403 |
tests/Feature/AttendeeApiTest |
attendee routes require auth; register/list/unregister flow; PUT/PATCH are 405 (no update verb) |
tests/Feature/AttendeeAuthorizationTest |
only the attendee may cancel their own registration (403 for anyone else, organiser included); {attendee} must belong to {event} or the route is 404 |
tests/Feature/AttendeeResourceShapeTest |
the attendee payload is exactly the field list openapi.yaml documents, and included relations go through their own resources |
tests/Feature/DuplicateAttendeeTest |
registering twice is idempotent — the second POST returns the existing row with 200, never a duplicate |
tests/Feature/CanLoadRelationshipsTest |
?include= parsing: multiple relations, whitespace, empty values, and unknown names that are ignored instead of 500ing |
tests/Feature/EventValidationTest |
StoreEventRequest rules on create and update, and that a client-supplied user_id can never spoof ownership |
tests/Feature/EventThrottleTest |
throttle:60,1 on writes — the 61st write in a minute is 429, reads are unaffected, the window resets |
tests/Feature/SendEventRemindersTest |
the reminder command's 24 h window — past events, both inclusive boundaries, one second past the edge, and events with no attendees |
tests/Feature/ScheduleTest |
app:send-event-reminders is registered on the schedule with a daily (0 0 * * *) expression |
tests/Feature/OpenApiSpecTest |
every /api route + method appears in openapi.yaml — add a route without documenting it and the suite fails |
tests/Feature/SmokeTest |
GET / 200; GET /api/events unauthenticated 401 |
tests/Unit/EventPolicyTest |
EventPolicy in isolation — update/delete/restore/forceDelete are owner-only, reads and create are open to any authenticated user |
Historical: the repo shipped with a Gate::(policyEvent::class, ...) syntax error that
broke every request and artisan command. It is fixed (Gate::policy(Event::class, EventPolicy::class)), but if you ever hit this class of failure,
php -l <file> finds the line instantly.
It isn't — seeding bcrypt-hashes 1000 user passwords and inserts thousands of attendee rows. Expect a couple of minutes.
The server starts in a background window and can need a few seconds beyond the recipe's
2-second grace. Poll curl.exe -s -o NUL -w "%{http_code}" http://127.0.0.1:8109/ — or
use just serve to watch startup in the foreground.
Pre-existing style debt. Keep your own diff clean; clear the backlog with just lint-fix
only as a dedicated chore: commit.
More in .docs/06-troubleshooting/common-issues.md.
event-management/
├─ app/
│ ├─ Console/Commands/ # SendEventReminders (app:send-event-reminders)
│ ├─ Http/Controllers/Api/ # AuthController, EventController, AttendeeController
│ ├─ Http/Requests/ # LoginRequest, StoreEventRequest
│ ├─ Http/Resources/ # Event/Attendee/User JSON resources
│ ├─ Http/Traits/ # CanLoadRelationships (?include= loading)
│ ├─ Models/ # User, Event, Attendee
│ ├─ Notifications/ # EventReminderNotification (mail, queued)
│ ├─ Policies/ # EventPolicy (owner-only writes), AttendeePolicy (attendee-only cancel)
│ └─ Providers/ # AppServiceProvider (Gate::policy)
├─ routes/api.php # login/logout, events, events.attendees, /user
├─ database/ # migrations, factories, seeders (1000 users / 200 events)
├─ tests/ # PHPUnit suite (auth, CRUD + ownership, reminders, OpenAPI, smoke)
├─ openapi.yaml # OpenAPI 3.0 spec — kept honest by OpenApiSpecTest
├─ resources/views/ # welcome.blade.php (only web page — the API landing page)
├─ justfile · setup.ps1 # dev recipes + one-time machine setup
├─ .docs/ # developer documentation (start at tldr.md)
└─ .claude/ # project skills, hooks, settings
