Local setup on Windows
Not a screen page — the rest of this folder is one page per screen. This one is here because the local runtime has a step that is invisible until it bites, and the symptom does not look like its cause.
The stack
PHP is Herd serving C:\Users\<you>\Herd\entrywick at https://entrywick.test (once it
is secured — see "Local HTTPS" below). MySQL, Redis and Mailpit run in Docker; Herd is not in a container and does not
need to be.
docker compose -f docker-compose.dev.yml up -d
composer install && npm ci && php artisan migrate --seed
Horizon runs in the containers. On Windows PHP has no pcntl, so Horizon cannot run here. After starting Docker,
run this one command and leave its terminal open:
composer dev
It runs three things side by side: the queue worker (queue:listen, which picks up code changes without a
restart), the scheduler (schedule:work, which fires relative reminders and takes events live every few minutes)
and Vite. Herd already serves the app, so it does not start php artisan serve, and it leaves out pail, which
needs pcntl. Left running overnight, the scheduler also runs the nightly jobs (entitlement sync, rollups, backup).
Without a worker, nothing queued happens — silently
Almost everything that leaves the app is queued: team invitations, confirmation and ticket emails, ticket images,
entitlement syncs. With no worker those jobs wait in Redis. Nothing fails, nothing is logged, and failed_jobs stays
empty, so "the email never arrived in Mailpit" looks like a mail problem when it is a missing worker. To drain a
backlog once without starting everything:
php artisan queue:work redis --stop-when-empty
An email that is still missing after that was never queued. Check the event's communication schedules: a schedule that
is paused sends nothing, and an event imported on a plan without reminders_configurable arrives with its reminders
paused until the plan is upgraded. The one exception is the fixed day-before reminder (is_fixed), which every plan
sends — it appears when the event is published, so a draft event has none.
Every tenant needs a hosts entry
This is the step that is easy to miss.
Tenants live on {slug}.{EW_BASE_DOMAIN} — alpha.entrywick.test, bravo.entrywick.test — and TenantMiddleware
resolves the tenant from the host before anything else touches the database. Herd registers entrywick.test, and
Windows has no wildcard DNS: *.entrywick.test does not resolve, so each subdomain needs its own line in the
hosts file. macOS and Linux dev boxes using dnsmasq get the wildcard for free, which is why this catches people
moving between them.
Edit C:\Windows\System32\drivers\etc\hosts as Administrator and add each tenant slug:
127.0.0.1 alpha.entrywick.test bravo.entrywick.test demo.entrywick.test
Do the same for any tenant you create later, including one from php artisan ew:demo-tenant. No restart is needed;
the browser picks it up on the next request.
The scanner app lives on its own host (ADR-025): entrywick.app in production, scan.entrywick.test locally
(EW_SCANNER_HOST). It needs its line too:
127.0.0.1 scan.entrywick.test
Herd needs no extra configuration for it: its nginx already serves every *.entrywick.test name to the app, and the
application routes scan.entrywick.test to the scanner rather than to a tenant (scan is a reserved slug).
API v1 (week 14) has its own host too: api.entrywick.io in production, api.entrywick.test locally
(EW_API_HOST), with paths starting /v1. Add its line and herd secure api.entrywick (below) before calling it:
127.0.0.1 api.entrywick.test
The documentation website (week 23) serves the pages under /docs/product: docs.entrywick.com in production,
docs.entrywick.test locally (EW_DOCS_HOST). Every "Help" link in the admin panel points at it (EW_HELP_URL), so
add its line if you want those links to open:
127.0.0.1 docs.entrywick.test
The status page (week 23) is public and has its own host too: status.entrywick.com in production,
status.entrywick.test locally (EW_STATUS_HOST), with /status.json for a monitor. It measures the stack it is
running on, so locally it is a quick way to see whether Docker's MySQL and Redis are actually up:
127.0.0.1 status.entrywick.test
The license host (week 21) answers self-hosted instances' heartbeats: license.entrywick.io in production,
license.entrywick.test locally (EW_LICENSE_HOST), POST /v1/heartbeat only. Nothing on a developer's machine calls
it unless you point a test instance's EW_LICENSE_HEARTBEAT_URL at it; then add 127.0.0.1 license.entrywick.test and
herd secure license.entrywick the same way.
Two traps, both seen on this box:
- Append on a new line. Herd writes its own block ending in
# End Herd generated Hosts, and text pasted onto the end of that line is part of the comment — so the entry is silently inert while looking present in the file. - A missing entry does not look like DNS. The browser says it cannot find the server, or — if a proxy or a search provider answers for the unknown name — you land somewhere else entirely. Nothing in the application logs, because the request never arrived. Check the hosts file first when a tenant subdomain "does not work".
Verify before assuming anything else is wrong:
curl -I https://alpha.entrywick.test
Local HTTPS — the scanner needs it
A browser gives the camera, service workers and crypto.randomUUID() only to a secure page: https, or localhost.
http://scan.entrywick.test is neither. Over plain http the scanner still pairs and scans with a hardware scanner or
typed codes (it makes its own ids), but it says "The camera needs a secure connection" instead of starting the camera,
and it cannot install itself or keep working offline after a reload. So local development uses Herd's HTTPS.
Secure every host you open — each is its own name. Herd's certificate for entrywick.test does not cover its
subdomains, so the app, the scanner host and each tenant subdomain need their own herd secure <name> (the name
without .test). Once, in any terminal:
herd secure entrywick
herd secure scan.entrywick
herd secure alpha.entrywick
And one for each other tenant subdomain you use, exactly as with the hosts file:
herd secure bravo.entrywick
herd secure demo.entrywick
Each makes a certificate for that name, signed by Herd's local certificate authority, and reloads Herd's nginx. The first
time, Windows may ask whether to trust Herd's authority — say yes, or browsers will warn on every page. A certificate
lasts 368 days; running the same command again renews it. A tenant you create later (php artisan ew:demo-tenant, a
signup) needs its hosts line and its herd secure <slug>.entrywick.
Check each one:
curl -I https://scan.entrywick.test
curl -I https://alpha.entrywick.test
A status line (200, 302) means the name is secured. A certificate error means that name was not secured yet: run its
herd secure line and check again.
The URLs follow. .env sets APP_URL=https://entrywick.test and EW_SCANNER_URL=https://scan.entrywick.test
(without EW_SCANNER_URL, the scanner's address takes APP_URL's scheme). Every link the app builds — tenant pages,
signed ticket links, the /scan redirect, roster downloads — then starts with https. After changing .env:
php artisan config:clear
Open the scanner at https://scan.entrywick.test (a laptop: https://scan.entrywick.test/?layout=laptop).
npm run dev inside apps/scanner proxies /api to https://scan.entrywick.test without checking the certificate,
because Node does not read the Windows certificate store. Tests and CI are unaffected: they use entrywick.localhost
over http, which browsers treat as secure.
If an existing migration file was edited, rebuild
While pre-release we still edit migration files in place rather than always adding new ones. A migration Laravel has already recorded never runs again, so editing one changes the schema for the next person who creates a database and changes nothing on yours. Your database and the code then disagree, silently, until something touches the column that only one of them has.
The symptom is a column that obviously should exist:
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'ulid' in 'groups'
There is no clever fix. Rebuild:
php artisan migrate:fresh --seed
This drops every table and re-seeds, so local data is gone — that is fine for demo data and is the whole reason the demo tenants are seeded rather than hand-made. If you have local state you care about, export it first.
Prefer a new migration file from now on. A new file runs on every database, including the ones that already exist, which is the difference between a schema change that reaches everyone and one that reaches only whoever rebuilds next. Edit an existing migration only for something no database has run yet, and say so in the commit.
After a pull
Migrations and platform rows are separate steps, and the second one is easy to skip:
php artisan migrate
php artisan db:seed --class=Database\\Seeders\\PlatformSeeder
PlatformSeeder is idempotent and re-seeds system roles, plans, and the event templates in /templates. Run it after any
pull that touched /templates — migrate --seed only seeds on a database it just created, so an existing local
database never learns about an event template added after it was made. That is exactly how the "Start from an event template"
dropdown ended up empty.
Known local limits
php artisan servedoes not bind on this box. The Playwright specs intests/e2e/still run here against PHP's built-in server withtests/e2e/router.phpand CI's environment — the steps are indocs/progress/STATE.md("Running the e2e suite locally").- PHPStan needs its memory limit raised;
composer testalready sets--memory-limit=1G.