Skip to content
EntryWick documentation

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:

  1. 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.
  2. 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 /templatesmigrate --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 serve does not bind on this box. The Playwright specs in tests/e2e/ still run here against PHP's built-in server with tests/e2e/router.php and CI's environment — the steps are in docs/progress/STATE.md ("Running the e2e suite locally").
  • PHPStan needs its memory limit raised; composer test already sets --memory-limit=1G.