Commands
phpunit.xml in parallel, one process per
CPU core (php artisan test --parallel, backed by ParaTest). Plain
php artisan test runs them in a single process, which is easier to read when
one test fails; --compact is useful when you only care about failures.
Under --parallel every process works on its own databases: Laravel creates a
<database>_test_<n> copy of the default connection per process, and the test
base classes do the same for the landlord connection. The copies survive between
runs to save migration time — add --recreate-databases after changing a
migration.
tests/Architecture. The low-level equivalent:
tests/Unit/Architecture/MigrationTest.php
invokes PHPStan. So composer test catches an architecture violation even when
test:arch is not run separately.
Redis is required
The session lock that keeps one contact’s flow on one worker lives in Redis, and mocks cannot show that its Lua scripts, key prefix and expiry behave. Tests intests/Feature/Redis (PHPUnit group redis) talk to a real Redis, using the
REDIS_* settings from .env. They are part of the default run and fail with a
message naming the host they tried when Redis is not reachable — start Redis
rather than skipping them. Each test uses its own keys and deletes only those, so
it is safe to point the suite at the Redis you develop against.
Load test
tools/loadtest/telegram-stub.php, so nothing reaches Telegram), then checks that
no contact or tenant ended up with another’s data and removes everything it
created. It is not part of composer test or CI; run it before a release or after
a change to tenancy, locking or the flow runtime. Do not try make -n loadtest:
GNU Make still executes the recipe lines that call $(MAKE).
Continuous integration
The same checks run in GitHub Actions (.github/workflows/ci.yml) on every
pull request and on every push to main. Three jobs run in parallel:
- PHP — Pint in
--testmode, the PHPUnit suites, the architecture rules. The suites run twice, as a matrix: once on SQLite in memory, exactly asphpunit.xmland the pre-commit hook run them locally, and once on PostgreSQL 15, which is what production uses. - Gateway —
go vetandgo testingateway/. - Frontend —
vue-tsctype check, Vitest, and the production Vite build.
main run
exists so that the commit a release tag points at has been checked as merged,
not only as proposed.
Running the suite on PostgreSQL locally
Create two empty databases — the default connection gets one, the landlord connection the other — and pass the connection settings on the command line. Variables that already exist in the environment win over the SQLite values inphpunit.xml, which is all the switch is:
DB_HOST, DB_USERNAME and DB_PASSWORD come from .env unless you set
them too, and the user needs the right to create databases: under --parallel
each process migrates its own _test_<n> copies of both. The tenant tables go
into a schema named main, the schema of the tenant the suite runs as, and the
landlord tables into public of the landlord database.
What the architecture rules enforce
They are the executable form of some of the boundaries described in this section. The rule classes areFlowRuntimeIsolationTest, HandlerVersionContractTest,
IdStrategyTest, MessagingBoundariesTest and MigrationTest:
- Migration isolation — no runtime state in
up()ordown() - Flow runtime isolation, handler versioning, ULID key strategy and messaging boundaries
- Dependency direction —
foundationandsupportare separate repositories with their own Composer requirements, so anApp\…import in them cannot autoload; no rule in this repository checks it - Tenant-aware execution — landlord
access from domains outside
Tenancyis caught in review only
The PHPat rules and the prose describing them are expected to agree. If you
change one, change the other — a rule that no document explains gets worked
around, and a document no rule enforces gets ignored.
Formatting
The gateway
The Go gateway carries its own suite, run fromgateway/:
That golden file is
contracts/ingress/golden.json, and both suites execute its
40 cases. A verification change that passes in one language and fails in the
other is precisely what it exists to catch — so extend the golden file rather
than either language’s own tests.
Deploying the gateway rather than changing it needs none of this: see
Webhook gateway.
What a good test covers
Test the contract, not the implementation. For a node handler that means the status, thesourceHandle per outcome, and the stateChanges — those are what
the engine and the flow author depend on.
Tenant isolation deserves explicit coverage wherever code touches persistence. Two
tenants, and an assertion that one cannot see the other’s rows, is worth more than
several tests of the happy path — it is the failure that matters most and shows up
least.
Retry safety deserves the same. Execute twice, assert the external effect happened
once. See Idempotency and retries.