A full-stack event management and registration platform. Attendees browse and register for events through a Vue 3 / Inertia SPA; organisers run everything from a Filament 5 admin panel.
- What it does
- Architecture
- Domain model
- Key flows
- Status model
- Features
- Tech stack
- Project structure
- Requirements
- Setup
- Development
- Environment variables
- Routes
- Code style and quality
- Design decisions
- Known gaps and roadmap
- Repository conventions
DevEvents has two distinct audiences served by one Laravel application:
| Audience | Surface | Technology |
|---|---|---|
| Attendees | Public site — browse events, register, manage their own registrations and profile | Vue 3 SPA over Inertia v2 |
| Organisers / admins | /admin panel — full CRUD over events, users and attendee registrations |
Filament 5 (Livewire) |
Both surfaces share one database, one session, one user table and one authorisation model. There is no separate API layer: Inertia ships props straight from the controller to the Vue page component.
flowchart TB
subgraph client["Client"]
SPA["Vue 3 SPA<br/>Inertia v2 + TypeScript"]
PANEL["Filament panel<br/>Livewire, server-rendered"]
end
subgraph laravel["Laravel 12 application"]
MW["Middleware<br/>HandleInertiaRequests · CheckUserStatus"]
HTTP["Controllers<br/>EventController · MyEventController · Settings"]
FIL["Filament resources<br/>Events · Users · Widgets"]
FORT["Laravel Fortify<br/>login · register · password reset"]
DOM["Domain events<br/>RegistrationConfirmed · EventReminder"]
SCHED["Scheduler<br/>app:send-event-reminders — daily 09:00"]
end
subgraph infra["Infrastructure"]
DB[("MySQL<br/>users · events · roles · pivots")]
QUEUE[["Queue — database driver<br/>jobs table"]]
DISK[["Public disk<br/>avatars, event images"]]
SMTP["SMTP / Mailtrap"]
end
SPA -->|"XHR + Inertia page props"| MW
PANEL --> MW
MW --> HTTP
MW --> FIL
MW --> FORT
HTTP --> DOM
SCHED --> DOM
DOM -->|"queued listeners"| QUEUE
QUEUE -->|"Mailable + PDF ticket"| SMTP
HTTP --> DB
FIL --> DB
FORT --> DB
HTTP --> DISK
FIL --> DISK
DISK -.->|"StorageUrlCast<br/>relative path to public URL"| SPA
Boundaries worth knowing
- Wayfinder generates TypeScript route/controller bindings into
resources/js/routesandresources/js/actionsat build time. These directories are git-ignored and regenerated — never edit them by hand. - Domain events are dispatched synchronously, but their listeners implement
ShouldQueue, so mail sending is pushed onto thedatabasequeue. A queue worker must be running for emails to go out. StorageUrlCastkeeps the database storing relative paths while models always expose absolute URLs, so the frontend never has to know about disks.
erDiagram
USERS ||--o{ USER_ROLES : has
ROLES ||--o{ USER_ROLES : grants
USERS ||--o{ ATTENDEES_EVENTS : registers
EVENTS ||--o{ ATTENDEES_EVENTS : hosts
USERS {
bigint id PK
string email UK
string password
boolean is_blocked "default false"
string first_name
string last_name
string avatar_url "nullable, StorageUrlCast"
string job_title "nullable"
string company "nullable"
timestamp deleted_at "soft delete"
}
EVENTS {
bigint id PK
string title
text description "nullable, rich text"
text image_url "nullable, StorageUrlCast"
datetime start_date "indexed"
datetime end_date "nullable"
string organization "nullable"
string venue_name "nullable"
string street "nullable"
string house_number "nullable"
string postal_code "nullable"
string city "nullable"
string province "nullable"
boolean is_published "default false"
boolean is_cancelled "default false"
timestamp deleted_at "soft delete"
}
ROLES {
bigint id PK
string name UK "admin or user"
}
USER_ROLES {
bigint user_id PK "FK to users"
bigint role_id PK "FK to roles"
timestamp assigned_at
}
ATTENDEES_EVENTS {
bigint user_id PK "FK to users"
bigint event_id PK "FK to events"
timestamp registered_at
timestamp reminder_sent_at "nullable — reminder idempotency"
}
Notes:
- Both pivots use composite primary keys, which makes double registration impossible at the database level.
attendees_events.reminder_sent_atis the idempotency marker for the reminder job — it is what stops a user being emailed twice about the same event.usersandeventsare soft-deleted; the pivots cascade on hard delete.- Status is derived, not stored — see Status model.
sequenceDiagram
autonumber
actor A as Attendee
participant V as Event.vue
participant C as EventController
participant DB as MySQL
participant Q as Queue - database driver
participant L as SendRegistrationConfirmedMail
participant M as Mailer
A->>V: Click "Register"
V->>C: POST /events/ID/register
C->>DB: exists() on attendees_events
alt not yet registered
C->>DB: attach(user, registered_at = now)
C->>Q: dispatch RegistrationConfirmed
else already registered
C-->>V: no-op (idempotent)
end
C-->>V: back() with isRegistered = true
Q->>L: handle(RegistrationConfirmed)
L->>L: Pdf::view('pdfs.ticket') to render ticket
L->>M: RegistrationConfirmationMail + ticket.pdf attachment
M-->>A: Confirmation email with PDF ticket
The register endpoint is idempotent: re-posting never creates a duplicate row and never sends a second email.
flowchart LR
CRON["Scheduler<br/>daily at 09:00"] --> CMD["app:send-event-reminders"]
CMD --> Q1{"Events where<br/>is_published = true<br/>is_cancelled = false<br/>start_date = tomorrow"}
Q1 --> Q2{"Attendees where<br/>reminder_sent_at IS NULL<br/>not soft-deleted<br/>not blocked"}
Q2 --> D["dispatch EventReminder(event, user)"]
D --> P["updateExistingPivot<br/>reminder_sent_at = now()"]
D --> QUEUE[["Queue"]]
QUEUE --> LSN["SendEventReminderMail"]
LSN --> MAIL["EventReminderMail"]
P -.->|"prevents re-send"| Q2
The command walks events with chunkById(100) so a large attendee base does not exhaust memory.
Register the scheduler on the server with a single cron entry:
* * * * * cd /path/to/event-crm && php artisan schedule:run >> /dev/null 2>&1flowchart TD
REQ["Incoming request"] --> WEB["web middleware group"]
WEB --> APPEAR["HandleAppearance<br/>light / dark cookie"]
APPEAR --> INERTIA["HandleInertiaRequests<br/>shares auth props"]
INERTIA --> STATUS["CheckUserStatus"]
STATUS --> BLOCKED{"is_blocked<br/>or soft-deleted?"}
BLOCKED -->|yes| LOGOUT["Auth::logout()<br/>invalidate session<br/>redirect to /login<br/>'Your account has been suspended.'"]
BLOCKED -->|no| ROUTE{"Route target"}
ROUTE -->|"public"| PUB["/, /events/{event}"]
ROUTE -->|"auth"| AUTHED["/my-events, /settings/*"]
ROUTE -->|"/admin"| PANEL{"canAccessPanel()<br/>roles contains 'admin'"}
PANEL -->|yes| FIL["Filament panel"]
PANEL -->|no| DENY["403"]
CheckUserStatus runs on every web request, so blocking or deleting a user from the admin panel ejects their live session on their next request — no waiting for the session to expire.
Neither Event nor User stores a status column. Status is computed from the underlying flags, so it can never drift out of sync with deleted_at, is_cancelled or end_date.
stateDiagram-v2
[*] --> Draft: created, is_published = false
Draft --> Published: is_published = true
Published --> Past: end_date < now()
Published --> Cancelled: is_cancelled = true
Draft --> Cancelled: is_cancelled = true
Cancelled --> Deleted: soft delete
Past --> Deleted: soft delete
Published --> Deleted: soft delete
Draft --> Deleted: soft delete
Deleted --> [*]
note right of Deleted
Event::getStatus() resolves in
priority order:
trashed > cancelled > past
> published > draft
end note
EventStatus and UserStatus are backed enums implementing Filament's HasLabel and HasColor, so the same enum drives both the admin table badges and the public colour coding.
EventStatus |
Resolves when | Badge colour |
|---|---|---|
Deleted |
deleted_at is set |
danger |
Cancelled |
is_cancelled = true |
warning |
Past |
end_date < now() |
gray |
Published |
is_published = true |
success |
Draft |
none of the above | info |
UserStatus |
Resolves when | Badge colour |
|---|---|---|
Deleted |
deleted_at is set |
danger |
Blocked |
is_blocked = true |
warning |
Active |
none of the above | success |
- Event browsing — the homepage lists published events with
start_date >= today, ordered by start date, with status-based colour coding. - Event detail page with register / unregister, reflecting the viewer's current registration state.
- Registration confirmation email carrying a generated PDF ticket as an attachment.
- Automatic reminder email the day before an event, sent once per attendee per event.
- Personal dashboard (
/my-events) splitting registrations into today, upcoming and past, with the ability to cancel a registration. - Profile management — avatar upload (resized to 300×300 WebP at 80% quality via
spatie/image), first/last name, job title, company, password change, account deletion. - Authentication via Laravel Fortify — login, registration, password reset.
- Light / dark appearance persisted in an unencrypted
appearancecookie and applied before first paint.
- Event management — rich text description, image upload, full location fields, date ranges, publication and cancellation toggles, soft deletes with restore.
- User management — avatar, block/activate, soft deletes with restore.
- Attendee registrations exposed as a relation manager on each event.
- Dashboard widgets — Popular events (upcoming, non-cancelled, ranked by registration count) and Recent unique registrations.
- Global search across users and events.
- Panel access control —
User::canAccessPanel()requires theadminrole; everyone else gets a 403.
| Category | Technologies |
|---|---|
| Core | Laravel 12, PHP 8.2+ (developed on 8.4), Vue 3, TypeScript 5, Inertia.js v2 |
| Styling & build | Tailwind CSS v4, Vite 7, Lucide icons, Reka UI |
| Admin & packages | Filament 5, Laravel Fortify, Laravel Wayfinder, Spatie Image, Spatie Laravel PDF (dompdf) |
| Database & mail | MySQL (SQLite works for local dev), Mailtrap / SMTP |
| Queue & cache | database driver for both |
| Tooling | Node.js 20+, ESLint 9, Prettier 3, Laravel Pint, vue-tsc, Laravel Pail, Laravel Herd |
event-crm/
│
├── app/
│ ├── Actions/Fortify/ User creation & password reset actions
│ ├── Casts/
│ │ └── StorageUrlCast.php Relative storage path to absolute public URL
│ ├── Concerns/ Shared validation rule traits
│ ├── Console/Commands/
│ │ └── SendEventReminders.php app:send-event-reminders (scheduled daily 09:00)
│ ├── Enums/
│ │ ├── EventStatus.php Draft | Published | Cancelled | Past | Deleted
│ │ └── UserStatus.php Active | Blocked | Deleted
│ ├── Events/
│ │ ├── Registration/ RegistrationConfirmed
│ │ └── Reminder/ EventReminder
│ ├── Filament/
│ │ ├── Resources/
│ │ │ ├── Events/ EventResource + Pages + Schemas + Tables
│ │ │ │ └── RelationManagers/ AttendeesRelationManager
│ │ │ └── Users/ UserResource + Pages + Schemas + Tables
│ │ └── Widgets/
│ │ ├── PopularEventsWidget.php
│ │ └── RecentRegistrationsWidget.php
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── EventController.php Browse, show, register, unregister
│ │ │ ├── MyEventController.php Personal dashboard
│ │ │ └── Settings/ Profile & password controllers
│ │ ├── Middleware/ CheckUserStatus, HandleAppearance, HandleInertiaRequests
│ │ └── Requests/Settings/ Form requests for profile & password
│ ├── Listeners/
│ │ ├── Registration/ SendRegistrationConfirmedMail (queued)
│ │ └── Reminder/ SendEventReminderMail (queued)
│ ├── Mail/
│ │ ├── RegistrationConfirmationMail.php attaches the PDF ticket
│ │ └── EventReminderMail.php
│ ├── Models/ Event.php, User.php, Role.php
│ └── Providers/
│ ├── AppServiceProvider.php
│ ├── FortifyServiceProvider.php
│ └── Filament/AdminPanelProvider.php
│
├── database/
│ ├── migrations/ users, events, roles, user_roles, attendees_events, jobs, cache
│ └── seeders/ DatabaseSeeder, UserSeeder, RoleSeeder, EventSeeder
│
├── resources/
│ ├── views/
│ │ ├── emails/ registration-confirmation, event-reminder
│ │ ├── pdfs/ticket.blade.php PDF ticket layout
│ │ └── filament/ sidebar footer render hook
│ ├── css/
│ │ ├── app.css Tailwind v4 entry
│ │ └── filament/admin/theme.css Filament panel theme
│ └── js/
│ ├── pages/
│ │ ├── Events.vue Public listing
│ │ ├── Event.vue Detail + registration
│ │ ├── MyEvents.vue Attendee dashboard
│ │ ├── auth/ Login, Register, Forgot/ResetPassword, VerifyEmail
│ │ └── profile/ Profile, Password, Appearance, DeleteAccount
│ ├── components/ EventCard, EventImage, AppHeader, AppFooter, ui/…
│ ├── composables/ useEventFormatter, useAppearance, useInitials
│ ├── layouts/ AppLayout, AuthLayout, app/, auth/, settings/
│ ├── actions/ Wayfinder-generated (git-ignored)
│ ├── routes/ Wayfinder-generated (git-ignored)
│ └── types/ auth, event, role, navigation, ui
│
└── routes/
├── web.php Public & authenticated routes
├── settings.php Profile / settings routes
└── console.php Scheduled commands
- PHP 8.2+ (the project is developed and verified on 8.4)
PHP extensions:
openssl,curl,mbstring,fileinfo,gd,exif,intl,zip, andpdo_mysqlorpdo_sqlite - Node.js 20+
- Composer 2
- MySQL 8 (or SQLite for local development)
- An SMTP endpoint — Mailtrap is the default in
.env.example
git clone https://github.com/HannaInIT/event-crm.git
cd event-crm
# One command: install deps, copy .env, generate key, migrate, build assets
composer run setupOr step by step:
composer install
cp .env.example .env
php artisan key:generate
# configure DB credentials in .env first
php artisan migrate --seed
php artisan storage:link # required for avatars and event images
npm install && npm run buildtouch database/database.sqlite
# in .env
# DB_CONNECTION=sqlite
# DB_DATABASE=database/database.sqlite
php artisan migrate:fresh --seed| Account | Password | Role | |
|---|---|---|---|
| Admin | admin@example.com |
password |
admin — full /admin access |
| Attendees | john@example.com, jane@example.com, peter@example.com, … |
password |
user |
| Blocked | blocked1@example.com, blocked2@example.com |
password |
user, is_blocked = true |
| Soft-deleted | deleted1@example.com, deleted2@example.com |
password |
user, deleted_at set |
composer run devStarts four processes concurrently via concurrently:
| Process | Command | Why it matters |
|---|---|---|
server |
php artisan serve |
HTTP server on http://127.0.0.1:8000 |
queue |
php artisan queue:listen |
Required — confirmation and reminder emails are queued |
logs |
php artisan pail |
Live log tail |
vite |
npm run dev |
HMR for Vue/Tailwind |
Useful one-offs:
php artisan app:send-event-reminders # run the reminder job manually
php artisan schedule:list # inspect the schedule
php artisan route:list # inspect routes
php artisan storage:link # publish the public disk symlink| Variable | Purpose | Example |
|---|---|---|
APP_URL |
Base URL — used in mail and PDF asset resolution | https://event-crm.test |
DB_CONNECTION |
mysql or sqlite |
mysql |
DB_DATABASE / DB_USERNAME / DB_PASSWORD |
Database credentials | hannas_event_crm / root / — |
QUEUE_CONNECTION |
Must be a real queue for mail to be delivered | database |
CACHE_STORE |
Cache backend | database |
FILESYSTEM_DISK |
Set to public so avatars and event images are web-accessible |
public |
MAIL_MAILER / MAIL_HOST / MAIL_PORT |
SMTP transport | smtp / sandbox.smtp.mailtrap.io / 2525 |
MAIL_USERNAME / MAIL_PASSWORD |
Mailtrap credentials | — |
MAIL_FROM_ADDRESS |
Sender identity | hello@devevents.test |
LARAVEL_PDF_DRIVER |
PDF renderer for tickets | dompdf |
FILESYSTEM_DISKdefaults tolocalin.env.example. Set it topublic(and runphp artisan storage:link) or uploaded images will not be reachable from the browser.
| Method | URI | Name | Auth |
|---|---|---|---|
GET |
/ |
home |
public |
GET |
/events/{event} |
event.show |
public |
POST |
/events/{event}/register |
event.register |
auth |
DELETE |
/events/{event}/unregister |
event.unregister |
auth |
GET |
/my-events |
my-events.index |
auth + verified |
DELETE |
/my-events/{event} |
my-events.destroy |
auth + verified |
GET PATCH |
/settings/profile |
profile.edit, profile.update |
auth |
DELETE |
/settings/profile |
profile.destroy |
auth + verified |
POST DELETE |
/profile/avatar |
profile.avatar, profile.avatar.destroy |
auth + verified |
GET PUT |
/settings/password |
user-password.* |
auth + verified |
GET |
/settings/appearance |
appearance.edit |
auth + verified |
GET |
/settings/delete |
profile.delete |
auth + verified |
GET |
/admin, /admin/events, /admin/users |
filament.admin.* |
admin role |
GET |
/up |
— | health check |
Fortify additionally registers /login, /register, /logout, /forgot-password, /reset-password, and /user/confirm-password.
# PHP — Laravel Pint
composer run lint # fix
composer run lint:check # verify
# JS / TS — ESLint + Prettier
npm run lint # eslint --fix
npm run format # prettier --write
npm run types:check # vue-tsc --noEmit
# Everything CI runs
composer run ci:checkConventions enforced by the toolchain:
- PHP follows the Laravel preset via Pint (
pint.json). - Vue/TS is formatted by Prettier (4-space indent, single quotes) and linted by ESLint 9 flat config.
.editorconfigand.gitattributesnormalise line endings across platforms.
Derived status instead of a status column.
An earlier schema had users.status as an enum. It was replaced by a boolean is_blocked plus deleted_at, with getStatus() deriving the label. This removes a whole class of bug where the status column and the soft-delete flag disagree.
Idempotency lives in the database.
Composite primary keys on both pivots make duplicate registrations impossible, and reminder_sent_at makes the reminder job safe to run repeatedly — a retried or double-scheduled run sends nothing extra.
Queued mail, synchronous dispatch. Controllers dispatch plain domain events; only the listeners are queued. The HTTP request stays fast, and the PDF rendering (the expensive part) happens off the request cycle.
One casting layer for file URLs.
StorageUrlCast transparently converts stored relative paths into public URLs and passes absolute URLs through untouched, so seeded placeholder images and real uploads behave identically in the UI.
Session-level enforcement of account state.
CheckUserStatus sits in the global web stack rather than on individual routes, so blocking a user takes effect on their very next request across both the SPA and the admin panel.
These are honest, currently-open items — not silently broken behaviour:
- Email verification is configured but not wired.
Features::emailVerification()is enabled inconfig/fortify.phpandauth/VerifyEmail.vueexists, butusershas noemail_verified_atcolumn andApp\Models\Userdoes not implementMustVerifyEmail. As a result theverifiedmiddleware short-circuits and every account is treated as verified. Completing it needs a migration, the contract on the model, and seeders marking existing users verified. - No automated test suite.
phpunit.xmlis present andcomposer run testis wired, but thetests/directory is currently empty, sophp artisan testruns zero tests. Feature tests for registration, the reminder command and panel authorisation are the highest-value first additions. end_dateis nullable whileEvent::getStatus()compares it tonow(). Events saved without an end date never resolve toPast.- Avatar cropping is client-side only; the server resizes to a fixed 300×300 square without honouring a crop box.
- Role checks are string-based (
roles()->where('name', 'admin')). A policy or enum-backed role would be more robust as permissions grow.
| Branch | Purpose |
|---|---|
main |
Latest working, fully merged state — the release branch |
develop |
Integration branch, kept in sync with main |
feature/HEC-* |
Jira-style feature branches, merged via pull request |
Commits follow Conventional Commits (feat:, fix:, chore:, docs:, refactor:, style:), and pull request titles are prefixed with the issue key, e.g. [HEC-12] [Filament] feat: implement admin users board.
Two directories are generated and must never be edited or committed: resources/js/routes and resources/js/actions (Wayfinder output, listed in .gitignore).