Skip to content

Latest commit

 

History

119 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DevEvents — Event CRM

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.

Laravel PHP Vue Inertia Filament Tailwind TypeScript


Table of contents


What it does

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.


Architecture

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
Loading

Boundaries worth knowing

  • Wayfinder generates TypeScript route/controller bindings into resources/js/routes and resources/js/actions at 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 the database queue. A queue worker must be running for emails to go out.
  • StorageUrlCast keeps the database storing relative paths while models always expose absolute URLs, so the frontend never has to know about disks.

Domain model

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"
    }
Loading

Notes:

  • Both pivots use composite primary keys, which makes double registration impossible at the database level.
  • attendees_events.reminder_sent_at is the idempotency marker for the reminder job — it is what stops a user being emailed twice about the same event.
  • users and events are soft-deleted; the pivots cascade on hard delete.
  • Status is derived, not stored — see Status model.

Key flows

Event registration

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
Loading

The register endpoint is idempotent: re-posting never creates a duplicate row and never sends a second email.

Daily event reminders

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
Loading

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>&1

Request pipeline and access control

flowchart 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"]
Loading

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.


Status model

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
Loading

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

Features

Public / attendee

  • 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 appearance cookie and applied before first paint.

Admin panel (Filament, /admin)

  • 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 widgetsPopular events (upcoming, non-cancelled, ranked by registration count) and Recent unique registrations.
  • Global search across users and events.
  • Panel access controlUser::canAccessPanel() requires the admin role; everyone else gets a 403.

Tech stack

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

Project structure

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

Requirements

  • PHP 8.2+ (the project is developed and verified on 8.4) PHP extensions: openssl, curl, mbstring, fileinfo, gd, exif, intl, zip, and pdo_mysql or pdo_sqlite
  • Node.js 20+
  • Composer 2
  • MySQL 8 (or SQLite for local development)
  • An SMTP endpoint — Mailtrap is the default in .env.example

Setup

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 setup

Or 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 build

Local development against SQLite

touch database/database.sqlite
# in .env
# DB_CONNECTION=sqlite
# DB_DATABASE=database/database.sqlite
php artisan migrate:fresh --seed

Seeded accounts

Account Email 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

Development

composer run dev

Starts 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

Environment variables

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_DISK defaults to local in .env.example. Set it to public (and run php artisan storage:link) or uploaded images will not be reachable from the browser.


Routes

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.


Code style and quality

# 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:check

Conventions 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.
  • .editorconfig and .gitattributes normalise line endings across platforms.

Design decisions

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.


Known gaps and roadmap

These are honest, currently-open items — not silently broken behaviour:

  • Email verification is configured but not wired. Features::emailVerification() is enabled in config/fortify.php and auth/VerifyEmail.vue exists, but users has no email_verified_at column and App\Models\User does not implement MustVerifyEmail. As a result the verified middleware 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.xml is present and composer run test is wired, but the tests/ directory is currently empty, so php artisan test runs zero tests. Feature tests for registration, the reminder command and panel authorisation are the highest-value first additions.
  • end_date is nullable while Event::getStatus() compares it to now(). Events saved without an end date never resolve to Past.
  • 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.

Repository conventions

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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages