Internal web application for Zone 3 Darwin, a laser tag venue in Darwin, NT. A modular admin shell — single-admin login behind mandatory TOTP 2FA, IP allowlisting, an append-only audit log, and encrypted nightly backups — with venue features delivered as independently enable/disable-able modules.
Two modules ship today: Free Play Arcades, free-play session control for ESP8266-driven arcade cabinets, and SMS Customer Outreach, the monthly birthday-radar campaign and inbound reply handling.
| Layer | Choice |
|---|---|
| Runtime | PHP 8.4 (Alpine), nginx, PHP-FPM, supervisord |
| Framework | Laravel 11 LTS |
| Modules | nwidart/laravel-modules + custom DB activator |
| UI | Blade + Tailwind 3 + Alpine.js (no Livewire) |
| Auth | Fortify (login only) + pragmarx/google2fa-laravel |
| Database | MariaDB 10.11 LTS |
| Cache / queue | Redis 7 |
| Testing | Pest 3 |
| Hosting | Coolify v4 on Docker |
Two modes — pick one.
Layer the local overlay (docker-compose.local.yml) on top of the base
compose file — the base file deliberately publishes no host ports
(Coolify's Traefik handles routing in production); the overlay adds
the ports you need on your laptop.
cp .env.example .env
# Generate an APP_KEY locally with `php artisan key:generate --show` if you
# have PHP installed, or use any base64-encoded 32-byte string.
docker compose -f docker-compose.yml -f docker-compose.local.yml \
--profile local up -d --build
docker compose exec app php artisan zone3:create-adminVisit http://127.0.0.1:8080/login and complete TOTP enrolment.
Nothing sends mail out of the box (MAIL_MAILER=log), but the local
profile starts Mailpit anyway for when a module does — its UI is at
http://127.0.0.1:8025.
Requires PHP 8.4+, Composer, and Node 20+ on the host.
cp .env.example .env
# Edit .env: DB_HOST=127.0.0.1, DB_USERNAME=zone3, DB_PASSWORD=devpassword,
# REDIS_HOST=127.0.0.1.
docker compose -f docker-compose.yml -f docker-compose.local.yml \
up -d mariadb redis
composer install
npm install && npm run build
php artisan key:generate
php artisan migrate --seed
php artisan zone3:create-admin
php artisan serveVisit http://127.0.0.1:8000/login.
CI runs the suite on every push and pull request
(.github/workflows/tests.yml), so the normal answer is "look at the PR".
Locally:
composer install # WITH dev dependencies — see the note below
./vendor/bin/pestTests run against a separate zone3_toolbox_test database — see phpunit.xml.
Create it once:
CREATE DATABASE zone3_toolbox_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL ON zone3_toolbox_test.* TO 'zone3'@'localhost';It isn't installed there, by design. The Dockerfile builds the vendor
directory with composer install --no-dev, so Pest, PHPUnit, Pint and
PHPStan — all require-dev — are absent from the runtime image. Inside the
container you get no such file or directory, and php artisan test fails
the same way for the same reason.
Shipping dev dependencies to production to fix that would be the wrong trade. Run tests on a host with dev dependencies installed, or let CI do it.
No npm run build is needed first: Tests\TestCase calls withoutVite(),
so the tests that render layouts.admin don't need a built asset manifest.
Coolify supports two deploy styles. Pick one based on whether you want
Coolify or this repo's docker-compose.yml to manage MariaDB and Redis.
Coolify provisions MariaDB and Redis as separate managed services. The
app container is built from the Dockerfile and linked to them via env
vars. Backups and resource limits stay inside Coolify's UI.
- Create a new application in Coolify pointing at this repository / branch. Build pack: Dockerfile.
- Add managed services: a MariaDB 10.11 LTS database and a Redis 7 instance. Note the hostnames Coolify assigns them.
- Set environment variables on the application (every entry from
.env.exampleis required — Coolify's "Environment Variables" tab makes this easy to paste in bulk):APP_KEY— generate withphp artisan key:generate --showlocally and paste thebase64:...string. Do not regenerate after first deploy or you'll lose access to all encrypted columns and backups.APP_URL— the public HTTPS URL Coolify exposes.DB_HOST,DB_DATABASE,DB_USERNAME,DB_PASSWORD,DB_PORTfrom the managed MariaDB service.REDIS_HOSTfrom the managed Redis service.TRUSTED_PROXIES=*(Coolify's Traefik sits in front; we trust its X-Forwarded-* headers).FORCE_HTTPS=true.
- Add a persistent volume for backups: mount path
/var/www/html/storage/app/backups. Coolify can then rotate this volume offsite (the dumps inside are already AES-256 encrypted withAPP_KEY). - Deploy. The container's entrypoint runs
php artisan migrate --forceandphp artisan db:seed --class=ModuleSeederautomatically. - One-time admin seed — open the Coolify "Terminal" for the running
container and run:
(you can pass
php artisan zone3:create-admin
--email=…and--password=…non-interactively for provisioning scripts; otherwise it prompts). - Add a Coolify scheduled task that runs
php artisan schedule:runevery minute. Laravel's scheduler dispatches the encrypted DB backup (02:00 Australia/Darwin) plus any schedules registered by enabled modules. - Visit the public URL, log in, finish TOTP enrolment, and you're live.
The compose file also binds host port
8050(the legacy free-play-arcades port) straight to the container so already-flashed ESP8266s keep working — override withARCADE_DEVICE_PORTif8050is taken. SeeModules/FreePlayArcades/README.md.
Coolify deploys the full docker-compose.yml as a single resource:
app + mariadb + redis + scheduler in one stack. Use this when you want
the whole thing on one host and don't mind Coolify treating it as one
unit. Mailpit stays out of the deploy because it sits in the local
profile, and the local-only port mappings live in
docker-compose.local.yml (which Coolify doesn't load — the base file
publishes no host ports, so Traefik routes via the Docker network and
nothing fights for :8080 on the host).
- Create a new resource in Coolify → Docker Compose, pointing at
this repository. The compose file path is
docker-compose.yml. - Set the same environment variables as Style 1 above. Coolify
substitutes them into the
${VAR}placeholders indocker-compose.ymlat deploy time; the defaults in that file are for local dev only. - Route Traefik to the
appservice on container port 8080 via Coolify's domain / proxy settings. Do NOT publish a host port — the compose file usesexpose: ["8080"]for exactly this reason; binding0.0.0.0:8080would collide with anything else on the same Coolify server. - First-time admin seed:
docker compose exec app php artisan zone3:create-admin - Backups are written to the
app_backupsnamed volume by both theappandschedulercontainers. Configure Coolify to back that volume up offsite.
/admin/modules lists every on-disk module with an enable/disable toggle:
- Free Play Arcades (
freeplay-arcades) — free-play windows on ESP8266-driven arcade cabinets, machine + group management, and the token-authenticated device API the cabinets poll. No dependencies; enabled by default on a fresh install. Details inModules/FreePlayArcades/README.md. - Bookeo (
bookeo) — shared Bookeo API client: bookings, products and customer records, with rate-limit pacing and credential redaction. An integration module, so no navigation, settings or guide. Enabled by default. Details inModules/Bookeo/README.md. - SMS (
sms) — shared 5c SMS client plus the mobile-number and message segment helpers. Also an integration module, named for the capability rather than the vendor so changing provider does not mean renaming a slug. Enabled by default. Details inModules/Sms/README.md. - SMS Customer Outreach (
crm) — the monthly birthday radar and the inbound reply checker. Depends onbookeoandsms, so neither can be switched off while it is on. Both of its schedules ship off — see below. The slug stayscrm: it keys themodulestable row, themodule:crmroute gate and everycrm.*setting, so renaming it would disable the module and orphan its configuration. Developer docs:Modules/Crm/README.md. Operator guide:Modules/Crm/GUIDE.md, also rendered in the app behind the User guide button on/admin/modules.
Migrated from two Claude Cowork Routines. Two scheduled jobs, both gated on their own setting and on the module being enabled:
| Job | Default schedule | Switch |
|---|---|---|
| Prepare radar batch | 1st of the month, 09:30 | crm.radar_enabled |
| Check SMS replies | hourly, 08:00–20:00 (13 runs) | crm.reply_checker_enabled |
Both default to false on purpose. The Cowork Routines they replace may
still be live, and running both means double-texting customers and double
Bookeo writes. Turn each on only after disabling its Cowork counterpart.
Everything in that table — day, time, interval, window and timezone — plus the
message templates themselves is editable at /admin/modules/crm/settings
(also linked as Settings under Customer Outreach (SMS)). That page is
generic: it renders whatever SettingDefinitions a module's manifest declares,
so a new knob is one entry in settings() rather than a controller and a view.
See Module settings for what it guarantees.
The radar never sends by itself. The scheduled job pulls Bookeo, applies
the exclusion passes, builds each message and validates it through the SMS
platform's simulate endpoint — then stops, leaving the batch awaiting
approval at /crm/radar. A human reads the exact per-recipient text and
presses approve; that click is the only path in the module that puts an SMS
in front of a customer, and it's recorded in the audit log against a user id.
Recipient numbers are only ever read from rows built by a Bookeo pull. Nothing in the module accepts a phone number as input — no one-off send path, no mobile field on the approve request. That is deliberate: a hand-typed number once went to the wrong parent, and this makes that class of mistake structurally impossible rather than merely discouraged.
Adding is local-only, because 5c v5 has no endpoint to create an opt-out.
Removing goes both ways: it calls DELETE /optouts/:number as well as deleting
the row, because the platform's list is polled back into this table on every
batch prepare — a local-only removal would silently reappear at the next radar
run.
Removal exists for correcting a mistake — a mistyped number, or someone suppressed by a send that went to the wrong person — not for reversing a customer's decision. Three things keep that honest:
- The confirmation names why the number is on the list, so removing a
manualtypo and overriding an explicit STOP don't feel like the same click. - Every removal is written to the audit log with a full snapshot of the row
(number, name, source, when they opted out, who removed it).
audit_logsis append-only behind a database trigger, so the compliance record outlives the row it describes — which the previous "edit the database by hand" answer did not manage. - If the platform refuses the removal, the local delete still happens (a mistyped number must not be stranded on the strength of an undocumented API response) but the page says so and tells you to check the 5c dashboard, rather than letting you discover it when the number returns.
Required env (see .env.example): BOOKEO_API_KEY, BOOKEO_SECRET_KEY,
ZONE3_SMS_KEY_ID, ZONE3_SMS_KEY_SECRET, ZONE3_OWNER_MOBILE, and
ZONE3_SMS_WEBHOOK_TOKEN if you want real-time opt-outs.
Both vendor APIs are transcribed in full, each alongside the client that
uses it — Modules/Bookeo/docs/ and
Modules/Sms/docs/. Read those rather than the vendor
sites. Writing this module against a hand-written summary produced four
silent bugs, including an opt-out feed that returned empty on every batch.
The hourly poll leaves up to an hour between a customer replying STOP and the exclusion list knowing about it. 5c can push instead:
php artisan crm:sms-webhook register --type=sms_optout # opt-outs, immediately
php artisan crm:sms-webhook register --type=sms_inbound # replies too
php artisan crm:sms-webhook list
php artisan crm:sms-webhook deliveries # what actually arrived
php artisan crm:sms-webhook replay --dry-run # what the parser makes of stored bodies
php artisan crm:sms-webhook replay # reprocess the unrecognised onesThe live payload uses UPPERCASE field names — MID, FROM, TO,
TIMESTAMP, MESSAGE, OPTOUT, REPLY_TO, IMAGES — form-encoded, with the
same pairs repeated in the query string. FROM is the customer and TO is our
VMN; confusing the two would opt out the VMN on every STOP and suppress no
actual customer, so TO is excluded twice over. OPTOUT=1 is the platform's
own verdict and outranks our stop-word list. One delivery is both an inbound
message and an opt-out, and both are recorded.
Every delivery is written raw before it is parsed, and that is what makes
replay possible. It earned its place: the field names were originally
inferred as lowercase, PHP array keys are case-sensitive, and nine real STOPs
were filed unrecognised and applied to nobody — then recovered from their
stored bodies. Replay runs the same WebhookDeliveryProcessor as the live
receiver, deliberately: a second copy of that parsing would be the same bug
with a longer fuse. It is safe to repeat (reply rows are unique on the
provider's message id, opt-outs are keyed by number) and does not notify unless
you pass --notify.
The receiver is POST /api/sms-webhook/{token}, with token =
ZONE3_SMS_WEBHOOK_TOKEN. That token is the only credential — 5c signs
nothing and offers no shared secret — so it is compared in constant time, a
mismatch is a flat 404, the route is rate-limited, and it must only ever be
served over HTTPS. Treat the value like a password; blank disables the
endpoint entirely, which is the default.
Two deliberate properties:
- The receiver is not behind the module gate, unlike the arcade device API. If a disabled module 404'd deliveries, 5c would eventually stop sending them and the webhook would be silently dead when the module came back on. Receiving a compliance instruction is safe whether or not we're sending; the toggle governs sending.
- The payload shape is inferred, because the vendor documents the
webhook management endpoints but never the body it POSTs. So every
delivery is stored raw in
crm_webhook_deliveriesbefore parsing, and anything unrecognised is kept rather than dropped. Checkcrm:sms-webhook deliveriesafter the first real one and tighten the field lists inSmsWebhookController.
The poll stays on as a backstop. Webhook and poll share one code path
(ReplyChecker::record()) and dedupe on the provider's message id, so
whichever arrives first wins and Robert is notified once.
Keep the local opt-out table rather than trusting 5c's list alone: on
24 July two customers replied STOP and GET /optouts stayed empty. Both
feeds are unioned, so either one catching it is enough.
Cutover, in order:
# 1. Seed from the Cowork project docs (parse first, then commit).
php artisan crm:import-cowork-state --ledger=radar-sent.md \
--opt-outs=opt-outs.md --cursor=inbox-state.md --dry-run
php artisan crm:import-cowork-state --ledger=radar-sent.md \
--opt-outs=opt-outs.md --cursor=inbox-state.md
# 2. Dry-run each side by hand before trusting a schedule.
php artisan crm:replies-check --no-notify
php artisan crm:radar-prepare --month=2026-10
# 3. Disable the Cowork Routine, then flip the matching setting on.The inbox cursor is the one value that must carry over exactly: set it too high and replies are missed silently, too low and every retained message is re-reported and re-mirrored onto customer files.
Disabling a module:
- Hides its sidebar links and 404s its routes via the
module:<slug>middleware. - Leaves its database tables and data intact (only the explicit "uninstall" action drops data).
- Keeps its CLI commands, observers, and queued jobs available so cross-module references don't break at runtime.
/admin/modules/{slug}/settings is one page shared by every module. It reads
the SettingDefinition list from the module's manifest and renders, validates,
casts and saves it — so a module never ships a settings controller or view, and
adding a knob is one array entry.
A definition carries a key, label, type (boolean, integer, string,
text, time, select), default, Laravel validation rules, help text, a
group heading, and options for selects.
Three properties are worth knowing about, because each exists in response to a way this could go wrong:
- It only ever writes keys the manifest declared.
app_settingsalso holds internal state — the reply checker's inbox cursor lives there — and a form that accepted arbitrary keys could rewind it, silently re-processing or skipping inbound replies. - Defaults have one home. A manifest's
default:and the fallback its service passes to$settings->get()used to be the same literal written twice, which drifts: change one and the page starts advertising a value nothing uses. CRM's live inCrmDefaults, read by both, with a test asserting they agree for every declared key. - Manifests can validate combinations and show consequences. Two optional
interfaces:
ValidatesSettingsfor rules no per-field string can express, andSummarisesSettingsfor the panels above the form. CRM uses both. Its validator composes sample SMS through the real message builder and refuses any set of templates that would drop the legally-required opt-out notice or the brand mention — a check that is impossible to express asrequired|stringand whose failure would otherwise only surface at send time. Its summary states the schedule in runs per day and shows the exact message each sample recipient would receive with its character, segment and credit count.
Preview without saving re-runs both hooks against the form's current values and writes nothing, which is the only safe way to read what a copy edit will actually send. Reset to defaults deletes the rows rather than rewriting them, so "never configured" and "configured to the default" stay the same state.
Cost is surfaced, not enforced: a message that spills past one SMS segment is flagged amber in the preview but still saves, because the degradation ladder has always been allowed to send a two-segment message when no variant fits. Losing a compliance phrase is the opposite — that is rejected outright.
A manifest that implements HasUserGuide gets a User guide button on
/admin/modules and a rendered page at /admin/modules/{slug}/guide. It
returns a path to a markdown file and a heading; the app renders it with
Str::markdown(), HTML stripped.
Markdown on disk rather than a Blade page, on purpose. It stays reviewable in a diff, greppable, and readable on GitHub by someone who cannot get into the admin at all — which is one of the situations a guide exists for. The button is only shown when the file actually exists, so a manifest pointing at something unwritten degrades to no link rather than a 404.
The CRM module's guide is the reference example: written for whoever is running the venue, not for a developer, with a troubleshooting section and the console commands relegated to an appendix.
There's no generator yet — copy the existing structure manually:
Modules/<Name>/
├── ModuleManifest.php # implements App\Modules\Contracts\ModuleManifest
├── composer.json # PSR-4 autoload
├── module.json # nwidart metadata; "providers": []
├── app/
│ ├── Http/Controllers/
│ ├── Models/
│ ├── Providers/
│ │ └── <Name>ServiceProvider.php
│ └── ...
├── database/migrations/ # picked up automatically
├── resources/views/ # loaded under the module slug namespace
└── routes/web.php # wrap groups with module:<slug> middleware
Then:
- Add the provider class to
bootstrap/providers.phpso its commands, observers, and bindings register at app boot. - Run
php artisan migrate— the AppServiceProvider auto-discoversModules/*/database/migrations. - Run
php artisan db:seed --class=Database\\Seeders\\ModuleSeederso the module appears in themodulestable. - Enable from
/admin/modules.
Backups live in storage/app/backups/backup-YYYY-MM-DD-HHmm.sql.gz.enc,
encrypted with AES-256-CBC + PBKDF2 using APP_KEY as the passphrase.
APP_KEY="base64:..." # the same APP_KEY that was set when the backup was taken
openssl enc -d -aes-256-cbc -pbkdf2 \
-pass "pass:${APP_KEY}" \
-in backup-2026-05-16-0200.sql.gz.enc \
| gunzip \
| mariadb -h <host> -u <user> -p<password> <database>If you lose the APP_KEY, the backup is unrecoverable. Store it in your
password manager alongside the database credentials.
| Concern | Where it lives |
|---|---|
| Single admin | app/Console/Commands/CreateAdminCommand.php; no /register |
| Mandatory 2FA | app/Http/Middleware/EnsureTwoFactorEnrolled.php |
| Login throttle | app/Support/Security/LoginThrottle.php (10 fails → 15 min) |
| IP allowlist | app/Support/Security/IpAllowlist.php, admin at /admin/ip-allowlist |
| Audit log | app/Models/AuditLog.php (model + DB trigger append-only) |
| Sessions | Redis, encrypted, SameSite=strict, httpOnly, 2-hour idle |
| HTTPS enforcement | FORCE_HTTPS=true + URL::forceScheme('https') |
| Backups encrypted | app/Console/Commands/BackupDatabaseCommand.php |
| Module route gate | app/Http/Middleware/EnsureModuleEnabled.php (module:<slug>) |
Note: the device API (/api/device/{token}/*) is deliberately session-less,
auth-less, and CSRF-exempt — the cabinet's device token in the URL is the
credential, because ESP8266 firmware can't hold a session. Keep those routes
on the LAN or behind the arcade device port rather than exposing them to the
public internet.
php artisan zone3:create-admin # seed/replace the single admin
php artisan zone3:backup # one-off encrypted DB dump
php artisan crm:radar-prepare # build next month's batch (never sends)
php artisan crm:radar-prepare --month=2026-10
php artisan crm:replies-check # one reply-checker pass
php artisan crm:replies-check --no-notify
php artisan crm:import-cowork-state --ledger=… --opt-outs=… --cursor=…