PHP Test Suite Performance Report
Scope and method
This task measures and optimizes local PHP test execution only. No GitHub Actions workflow changes, CI benchmark runs, or claims about CI performance are included. The suite is run serially against the dedicated PostgreSQL test database because tenant tests create/drop databases and the concurrency group exercises independent PostgreSQL connections and lock behavior.
Wall time is measured by a PowerShell Stopwatch; PHPUnit JUnit XML supplies test/assertion counts, per-test durations, and suite subtotals. The test command explicitly forces APP_ENV=testing, DB_CONNECTION=pgsql, and DB_DATABASE=elec_parts_test. All command logs and JUnit files are stored under the ignored storage/app/php-test-benchmarks/20261012-baseline/ directory and are not committed.
BEFORE — unchanged origin/dev
Environment and safety
- Baseline source SHA:
b58fd104bcf5866fcb25c726afb0af99272e0efe(origin/devon 2026-10-12). - Invocation: native Windows PowerShell and PHP CLI, not Docker or WSL. OS runtime identifies Windows 10.0.26200; 24 logical processors; .NET reports 31.7 GiB total available memory. The operating-system memory query was unavailable to the sandbox.
- PHP 8.4.23 x64 (Herd), PHPUnit 11.5.27, Composer 2.9.5.
- PostgreSQL 18.4 on Debian x86_64, reached through
127.0.0.1:5432and reported by PostgreSQL as172.17.0.3:5432. pdo_pgsqlandpgsqlare loaded. Xdebug is not loaded; code coverage was disabled.- PHP's configured memory limit is 128 MB. The benchmark uses a process-local unlimited limit because the existing suite exceeds that limit; the unmodified suite reported 152 MB peak memory.
- Before destructive test operations, a read-only connection confirmed the active database was
elec_parts_test, distinct from the local development databaseelecparts. The fixed fixture databasetest_tenant_foowas absent. Tenant databases created by the normal test fixture use the test PostgreSQL instance and are removed by the existing teardown lifecycle. - The host's Docker CLI could not access the Docker Desktop API. The PHP suite ran natively and connected to PostgreSQL through the local port forward.
Reproducible command
From the repository root in PowerShell:
$env:APP_ENV = 'testing'
$env:DB_CONNECTION = 'pgsql'
$env:DB_DATABASE = 'elec_parts_test'
$timer = [System.Diagnostics.Stopwatch]::StartNew()
php -d memory_limit=-1 .\vendor\bin\phpunit --log-junit 'storage/app/php-test-benchmarks/20261012-baseline/full-run-1.junit.xml' *> 'storage/app/php-test-benchmarks/20261012-baseline/full-run-1.log'
$exitCode = $LASTEXITCODE
$timer.Stop()Full run 1
Started 2026-10-12 22:26:53 +02:00. The suite completed successfully.
| Measure | Result |
|---|---|
| Wall time | 1,378.116 s (22:58.116) |
| PHPUnit time from JUnit | 1,377.417 s |
| Tests / assertions | 609 / 5,245 |
| Failures / errors / skipped | 0 / 0 / 0 |
| Exit status | 0 |
| PHPUnit peak memory | 152.00 MB |
This first run is one observed baseline, not a stable average. A second unchanged-branch run is recorded below; direct lifecycle samples are still pending.
Suite and database-backed timing from JUnit
| Subset | Tests | Assertions | Run 1 JUnit time | Run 2 JUnit time |
|---|---|---|---|---|
| Unit | 6 | 85 | 1.499 s | 1.518 s |
| Integration | 333 | 1,923 | 892.439 s | 950.727 s |
| Feature | 270 | 3,237 | 483.479 s | 534.580 s |
Central-base tests (PostgresDatabaseTestCase) | 114 | 980 | 131.790 s | 151.936 s |
Tenant-base tests (PostgresTenantTestCase) | 480 | 3,642 | 1,243.878 s | 1,332.610 s |
| Other tests | 15 | 623 | 1.749 s | 2.279 s |
The central/tenant classification is derived from each test class's base class. JUnit duration includes test setup and teardown; it does not itself split the test method from fixture work. The full suite includes all 16 tests in the concurrency group. Their three classes account for 210.045 s in this run; this class total includes the whole class lifecycle and is not a separate group-only invocation.
Slowest tests observed in full run 1
| JUnit duration | Test |
|---|---|
| 22.582 s | PostgresStockConcurrencyTest::test_remove_holding_waits_for_a_move_into_it_then_scraps_the_authoritative_balance |
| 22.470 s | PostgresStockConcurrencyTest::test_competing_explicit_stock_moves_serialize_without_a_deadlock |
| 22.395 s | PostgresStockConcurrencyTest::test_competing_moves_create_one_destination_holding_for_a_lot_and_location |
| 22.323 s | PostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_stock_move |
| 15.077 s | DocumentationSeedCommandTest::test_it_builds_a_repeatable_documentation_tenant_without_mutating_an_unrelated_tenant |
| 12.632 s | PostgresStockConcurrencyTest::test_competing_reservations_cannot_exceed_a_holding_available_quantity |
| 12.614 s | PostgresStockConcurrencyTest::test_competing_build_matching_cannot_double_reserve_a_part |
| 12.502 s | PostgresPurchaseListItemConcurrencyTest::test_concurrent_custom_creates_produce_one_case_sensitive_line_with_the_summed_quantity |
| 12.496 s | PostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_draft_assignment |
| 12.484 s | PostgresStockConcurrencyTest::test_commit_wins_over_a_stale_draft_delete_and_preserves_the_durable_receipt |
The concurrency tests intentionally wait on PostgreSQL locks and include independent worker processes; their time is part of the correctness contract.
Baseline runs and lifecycle profiling
| Run | Condition | Wall time | Tests / assertions | Result |
|---|---|---|---|---|
| 1 | First measured run; cache state uncontrolled | 1,378.116 s | 609 / 5,245 | Pass |
| 2 | Immediate serial repeat; same command and environment | 1,487.833 s | 609 / 5,245 | Pass |
The mean wall time is 1,432.975 s (23:52.975); the run-to-run range is 109.717 s (7.66% of the mean), and run 2 was 7.96% slower than run 1. Both runs reported 152.00 MB peak memory, 0 failures/errors/skips, and exit status 0. The variation is material and will be retained when judging the after result.
Direct lifecycle samples and measured candidate
These samples were run serially against the same test database after the two full baselines and before changing test code. The central migrate:fresh samples each used a fresh php artisan process. Tenant lifecycle samples used unique UUID tenant IDs, the normal synchronous Stancl TenantCreated pipeline, tenant initialization, and normal tenant deletion. The harness verified the central database and default tenant prefix and cleaned each sample in finally; no transaction surrounded database creation or deletion.
| Operation | Samples | Mean | Median | Range |
|---|---|---|---|---|
Central migrate:fresh --force | 5 | 1.024 s | 1.009 s | 0.994–1.092 s |
| Tenant database creation | 5 | 0.0366 s | 0.0372 s | 0.0300–0.0451 s |
| Tenant migrations (real Stancl pipeline) | 5 | 1.143 s | 1.130 s | 0.868–1.367 s |
| Tenant create + migrate | 5 | 1.181 s | 1.176 s | 0.900–1.407 s |
| Tenant initialization | 5 | 0.00045 s | 0.00031 s | 0.00017–0.00085 s |
| Tenant teardown and database deletion | 5 | 0.0676 s | 0.0294 s | 0.0245–0.2206 s |
Template cloning was benchmarked as a candidate, not yet adopted. A uniquely named template tenant was first created and migrated through the real TenantCreated pipeline. After disconnecting its database connection and confirming there were no active template clients, five serial CREATE DATABASE ... TEMPLATE ... operations were measured outside transactions. Each clone was connected to for a schema metadata comparison, disconnected, then dropped.
| Operation | Samples | Mean | Median | Range |
|---|---|---|---|---|
| One-time real template creation + migration | 1 | 1.218 s | — | — |
| Clone database creation | 5 | 0.0310 s | 0.0279 s | 0.0270–0.0365 s |
| Clone database drop | 5 | 0.0690 s | 0.0235 s | 0.0230–0.2469 s |
Every clone matched the migrated template's schema signature: 38 tables, 95 indexes, 344 constraints, 347 columns, 1 non-internal trigger, 1 routine, and 30 migration rows. This confirms the PostgreSQL clone retained the measured schema metadata in these samples. It does not by itself prove the test harness lifecycle safe; that remains subject to implementation, regression validation, and independent review.
At this baseline point no optimization code had been changed. The profile results and post-change measurements are recorded below.
AFTER — test harness changes and measured results
Implementation
PostgresDatabaseTestCasenow checksAPP_ENV=testing, PostgreSQL, and the exact disposable database nameelec_parts_test. It runs centralmigrate:fresh --forceonce per PHPUnit process. Before every later database-backed test it truncates all public tables exceptmigrations, withRESTART IDENTITY CASCADE. This remains outside a transaction, so independent connections observe committed data andafterCommitcallbacks retain their normal behavior.- Ordinary
PostgresTenantTestCasetests clone a tenant database from one per-process template. The template itself is built by the real synchronousTenantCreatedpipeline. Each fixture gets its own PostgreSQL database; its tenant row is created without the creation event because the database already exists. Teardown still ends tenancy and calls the normalTenant::delete()path, which dispatches the normal database deletion pipeline. TenantSchemaContractsTestandPurchaseRequestAllocationTest::test_fresh_tenant_migrations_create_the_canonical_purchasing_schemaexplicitly opt out of cloning and continue to run fresh tenant creation and migrations. Provisioning tests that extendPostgresDatabaseTestCasealso continue to use the realTenant::createlifecycle.- Template and clone names include a random per-process token and the standard
tenantprefix, so subprocess workers can resolve the same database from the persisted tenant ID. A PostgreSQL advisory lock protects each process namespace. Before cloning, the harness polls for up to one second for the template to have no connected clients, then fails with session details if any remain. Startup and shutdown cleanup only considers the test-specific database-name pattern, checks ownership locks and active sessions, and retries abandoned database cleanup on a later test run. PostgreSQLCREATE DATABASEandDROP DATABASEremain outside transactions.
No production code, migrations, CI files, business assertions, database engines, or test groups were changed.
Full suite and suite-level results
The same serial command and environment used for the baseline was run twice consecutively. JUnit times below are sums from the same selected tests; full-suite wall time is the PowerShell stopwatch measurement.
| Run | Wall time | PHPUnit/JUnit time | Tests / assertions | Failures / errors / skipped | Peak memory | Result |
|---|---|---|---|---|---|---|
| Before 1 | 1,378.116 s | 1,377.417 s | 609 / 5,245 | 0 / 0 / 0 | 152 MB | Pass |
| Before 2 | 1,487.833 s | 1,486.825 s | 609 / 5,245 | 0 / 0 / 0 | 152 MB | Pass |
| After 1 | 546.332 s | 545.199 s | 609 / 5,246 | 0 / 0 / 0 | 154 MB | Pass |
| After 2 | 585.510 s | 584.201 s | 609 / 5,246 | 0 / 0 / 0 | 154 MB | Pass |
The mean wall time fell from 1,432.975 s to 565.921 s: 60.51% lower, or 2.53× faster. After-run range was 39.178 s (6.92% of its mean), compared with 109.717 s (7.66%) before. The measured reduction is much larger than the observed run-to-run variation.
The single extra assertion after the change comes from assertSuccessful() on the once-per-process migrate:fresh command. Test discovery is unchanged at 609 tests and every run has zero skips, failures, or errors.
| Subset (JUnit time) | Tests | Before mean | After mean | Change |
|---|---|---|---|---|
| Unit | 6 | 1.509 s | 1.500 s | 0.6% lower |
| Integration | 333 | 921.583 s | 388.431 s | 57.9% lower |
| Feature | 270 | 509.030 s | 174.775 s | 65.7% lower |
| Central-base tests | 114 | 141.863 s | 88.075 s | 37.9% lower |
| Tenant-base tests | 480 | 1,288.244 s | 474.324 s | 63.2% lower |
| Other tests | 15 | 2.014 s | 2.300 s | 14.2% higher |
The total class-base categories partition all 609 tests. The minor increase in Other is about 0.3 seconds and does not affect the full-suite result.
Concurrency subset
All 16 methods tagged concurrency passed as part of both full runs. Summing those same tagged test cases from the full-run JUnit reports gives 210.045 s and 216.747 s before, and 187.350 s and 187.850 s after. The after mean is 12.1% lower.
The separate after command below also passed all 16 tagged tests and 118 assertions in 191.069 s wall time (190.631 s JUnit). A standalone --group=concurrency command was not run before the changes; the before comparison uses the exact tagged cases within the full-suite JUnit files rather than claiming a standalone baseline.
$env:APP_ENV = 'testing'
$env:DB_CONNECTION = 'pgsql'
$env:DB_DATABASE = 'elec_parts_test'
php -d memory_limit=-1 .\vendor\bin\phpunit --group=concurrency --log-junit 'storage/app/php-test-benchmarks/20261013-after/concurrency-group.junit.xml'The concurrency tests retain their independent PostgreSQL connections, worker processes, lock waits, and database DDL. Their duration is still a material part of the suite, as intended.
Setup, body, and direct database lifecycle measurements
The baseline event profiler was deliberately stopped after 170 of 609 methods because its instrumentation was progressing more slowly than the uninstrumented suite. It was not included in baseline wall-time results. The post-change event profile covered a randomized 67-test mixed subset. Of those, 35 methods overlapped the partial baseline profile, allowing a paired setup/body comparison:
| Measure for 35 matching methods | Before | After |
|---|---|---|
| Mean setup time | 1.91 s | 0.22 s |
| Median setup time | 2.01 s | 0.19 s |
| Mean test-body time | 0.35 s | 0.35 s |
| Median test-body time | 0.31 s | 0.33 s |
The post profile includes the first template creation and two real-migration contract tests, so its setup maximum was 1.57 s. Typical cloned-tenant setup remained around 0.19 s. The body timing stayed similar, showing that the full-suite gain came from fixture work.
Five fresh-process central migration samples after the change averaged 0.986 s (median 0.979 s, range 0.941–1.053 s), close to the baseline 1.024 s mean. The optimized harness pays this migration cost once per process rather than before each of 594 database-backed tests.
Five direct runs through the new fixture helper measured:
| Operation | Samples | Mean | Median | Range |
|---|---|---|---|---|
| Real template create + migrate, once per process | 1 | 1.274 s | — | — |
| Per-test fixture setup including owner/org and clone | 5 | 0.216 s | 0.138 s | 0.120–0.552 s |
| Clone operation including tenant row and database comment | 5 | 0.0396 s | 0.0368 s | 0.0337–0.0462 s |
| Tenant initialization | 5 | 0.00049 s | 0.00029 s | 0.00026–0.00094 s |
| Teardown including normal delete pipeline and owner/org cleanup | 5 | 0.126 s | 0.0413 s | 0.0377–0.461 s |
The first fixture setup and teardown samples include connection and database startup costs and are outliers; the median reflects the repeated per-test fixture path. Every clone had migration history. Separate randomized tests passed PostgreSQL constraint enforcement and tenant DDL mutation cases.
The real tenant provisioning path was also measured five times after the change using the same direct lifecycle harness as before. Create plus migration averaged 1.267 s (median 1.319 s, range 1.034–1.380 s); tenant migration averaged 1.228 s (median 1.282 s, range 1.001–1.342 s); normal database deletion averaged 0.032 s (median 0.030 s, range 0.028–0.039 s). This path still performs real migrations; no speed claim is made for it.
Slowest tests after the change
JUnit durations from After run 2:
| JUnit duration | Test |
|---|---|
| 21.20 s | PostgresStockConcurrencyTest::test_competing_moves_create_one_destination_holding_for_a_lot_and_location |
| 21.16 s | PostgresStockConcurrencyTest::test_competing_explicit_stock_moves_serialize_without_a_deadlock |
| 21.10 s | PostgresStockConcurrencyTest::test_remove_holding_waits_for_a_move_into_it_then_scraps_the_authoritative_balance |
| 20.96 s | PostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_stock_move |
| 18.97 s | DocumentationSeedCommandTest::test_it_builds_a_repeatable_documentation_tenant_without_mutating_an_unrelated_tenant |
| 11.19 s | PostgresStockConcurrencyTest::test_competing_build_matching_cannot_double_reserve_a_part |
| 11.16 s | PostgresStockConcurrencyTest::test_commit_wins_over_a_stale_draft_delete_and_preserves_the_durable_receipt |
| 11.11 s | PostgresStockConcurrencyTest::test_competing_reservations_cannot_exceed_a_holding_available_quantity |
| 11.11 s | PostgresStockConcurrencyTest::test_delete_wins_over_draft_commit_without_posting_stock |
| 11.11 s | PostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_draft_assignment |
The lock-focused concurrency methods remain the longest cases and continue to run unchanged.
Commands and additional validation
The full-suite after command was identical to the baseline command except for the JUnit/log output paths:
$env:APP_ENV = 'testing'
$env:DB_CONNECTION = 'pgsql'
$env:DB_DATABASE = 'elec_parts_test'
php -d memory_limit=-1 .\vendor\bin\phpunit --log-junit 'storage/app/php-test-benchmarks/20261013-after/full-run-1.junit.xml'It was repeated immediately with full-run-2.junit.xml. The randomized integration subset used seed 20261013 and included the central disposable-domain test, tenant schema contracts, all PostgreSQL constraint tests, category tests, inventory reconciliation DDL tests, and the full purchase-request allocation class. All 67 tests and 364 assertions passed in 46.3 s; a post-change event-profile repeat passed the same 67 tests and assertions in 46.0 s.
Other passing checks after the change:
TenantSchemaContractsTest: 1 test, 21 assertions, real tenant migrations.PurchaseRequestAllocationTest: 32 tests, 193 assertions, including its fresh-migration opt-out, schema changes, and child workers.InventoryReconciliationServiceTest: 14 tests, 51 assertions.PostgresConstraintEnforcementTest: 8 tests, 21 assertions.ExpiredTenantSessionRedirectTest: 5 tests, 39 assertions with the final worker-token length.- Pint on all changed PHP files and
git diff --check.
The first focused PurchaseRequestAllocationTest class attempt had one setup error when the template-session guard saw a connected client. The immediate rerun passed, indicating a transient session, but the independent review correctly flagged that the guard failed immediately and did not provide session details. The guard now polls for up to one second and includes any remaining session details in its failure. The final post-fix class rerun passed all 32 tests and 193 assertions in 20.9 s; the successful full-suite and randomized runs above also passed.
A read-only database check after benchmarks found zero leftover test-template databases, test_tenant_foo absent, and the elecparts development database unchanged at 16,529,087 bytes. Test commands explicitly used elec_parts_test.
The profiling extension, raw logs, JUnit XML, and temporary randomized PHPUnit configuration are under the ignored storage/app/php-test-benchmarks/ directory and are not committed. The tracked report contains the benchmark results and commands.
Correctness, review, and decision
The optimization is worth keeping locally: the full suite is 60.51% faster on average across two repeated runs, with the same 609 discovered tests and no skipped or failed tests. Central migration and tenant migration contract coverage remain real PostgreSQL tests. Ordinary tenant fixtures retain separate PostgreSQL databases and normal deletion behavior.
The independent review found no blocking correctness issue and raised one P2 follow-up for the transient template-session error. The bounded poll and diagnostic message address that finding, and the final review recheck passed with no blocking findings. No changes have been made to dev, and no merge or CI work is included.
Remaining limits and risks
- The one-time template setup migrates a real tenant schema at process startup, so each PHP worker still pays about 1.3 s once.
- A hard process kill can leave a test-specific database until the next test run; startup cleanup uses advisory locks and active-session checks and will not drop a database while a worker is still connected. A long-lived orphaned external worker can therefore delay cleanup safely.
- The central test fixture guard intentionally accepts only
APP_ENV=testingwith the project’s configuredelec_parts_testdatabase. A different local test database name fails closed and requires an explicit harness update. - No local parallel test runner was enabled. All measured runs were serial; the inner PostgreSQL worker tests remain concurrent by design.
- Measurements are local Windows/PHP/PostgreSQL results only. No CI runtime was measured and no CI performance claim is made.