Skip to main content
Every code change carries a minimal relevant test, and that test is run.

Commands

Runs the PHPUnit suites declared in 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.
Runs the PHPat architecture rules through PHPStan. The rules live in tests/Architecture. The low-level equivalent:
Do not run php artisan test tests/Architecture as the architecture check. Those classes are not PHPUnit TestCases — the command reports success while verifying nothing, which is worse than not running it at all.
The default PHPUnit run does cover PHPat, indirectly: 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 in tests/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.
Runs only that group.

Load test

Runs the real pipeline — webhook HTTP, Redis, PostgreSQL tenant schemas, queue workers — against throwaway tenants and a local stand-in for the Telegram Bot API (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 --test mode, the PHPUnit suites, the architecture rules. The suites run twice, as a matrix: once on SQLite in memory, exactly as phpunit.xml and the pre-commit hook run them locally, and once on PostgreSQL 15, which is what production uses.
  • Gateway — go vet and go test in gateway/.
  • Frontend — vue-tsc type check, Vitest, and the production Vite build.
A pull request is merged only when all of them are green. The push-to-main run exists so that the commit a release tag points at has been checked as merged, not only as proposed.
The PostgreSQL leg is the only place schema-per-tenant switching, partitioned tables and uuid columns are exercised for real; SQLite approximates them. A test that is green locally on SQLite can still fail there, so read a PostgreSQL-only failure as a real finding, not as CI noise.

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 in phpunit.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 are FlowRuntimeIsolationTest, HandlerVersionContractTest, IdStrategyTest, MessagingBoundariesTest and MigrationTest:
  • Migration isolation — no runtime state in up() or down()
  • Flow runtime isolation, handler versioning, ULID key strategy and messaging boundaries
Two boundaries have no PHPat rule and are not checked by it:
  • Dependency direction — foundation and support are separate repositories with their own Composer requirements, so an App\… import in them cannot autoload; no rule in this repository checks it
  • Tenant-aware execution — landlord access from domains outside Tenancy is 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

Run after changing PHP.

The gateway

The Go gateway carries its own suite, run from gateway/:
Run make check before shipping a gateway build. Its spec executor is held to the same golden file as the PHP implementation, so a regression here is a regression in webhook verification for every channel.
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, the sourceHandle 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.