Skip to content

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/dev on 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:5432 and reported by PostgreSQL as 172.17.0.3:5432.
  • pdo_pgsql and pgsql are 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 database elecparts. The fixed fixture database test_tenant_foo was 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:

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.

MeasureResult
Wall time1,378.116 s (22:58.116)
PHPUnit time from JUnit1,377.417 s
Tests / assertions609 / 5,245
Failures / errors / skipped0 / 0 / 0
Exit status0
PHPUnit peak memory152.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 ​

SubsetTestsAssertionsRun 1 JUnit timeRun 2 JUnit time
Unit6851.499 s1.518 s
Integration3331,923892.439 s950.727 s
Feature2703,237483.479 s534.580 s
Central-base tests (PostgresDatabaseTestCase)114980131.790 s151.936 s
Tenant-base tests (PostgresTenantTestCase)4803,6421,243.878 s1,332.610 s
Other tests156231.749 s2.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 durationTest
22.582 sPostgresStockConcurrencyTest::test_remove_holding_waits_for_a_move_into_it_then_scraps_the_authoritative_balance
22.470 sPostgresStockConcurrencyTest::test_competing_explicit_stock_moves_serialize_without_a_deadlock
22.395 sPostgresStockConcurrencyTest::test_competing_moves_create_one_destination_holding_for_a_lot_and_location
22.323 sPostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_stock_move
15.077 sDocumentationSeedCommandTest::test_it_builds_a_repeatable_documentation_tenant_without_mutating_an_unrelated_tenant
12.632 sPostgresStockConcurrencyTest::test_competing_reservations_cannot_exceed_a_holding_available_quantity
12.614 sPostgresStockConcurrencyTest::test_competing_build_matching_cannot_double_reserve_a_part
12.502 sPostgresPurchaseListItemConcurrencyTest::test_concurrent_custom_creates_produce_one_case_sensitive_line_with_the_summed_quantity
12.496 sPostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_draft_assignment
12.484 sPostgresStockConcurrencyTest::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 ​

RunConditionWall timeTests / assertionsResult
1First measured run; cache state uncontrolled1,378.116 s609 / 5,245Pass
2Immediate serial repeat; same command and environment1,487.833 s609 / 5,245Pass

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.

OperationSamplesMeanMedianRange
Central migrate:fresh --force51.024 s1.009 s0.994–1.092 s
Tenant database creation50.0366 s0.0372 s0.0300–0.0451 s
Tenant migrations (real Stancl pipeline)51.143 s1.130 s0.868–1.367 s
Tenant create + migrate51.181 s1.176 s0.900–1.407 s
Tenant initialization50.00045 s0.00031 s0.00017–0.00085 s
Tenant teardown and database deletion50.0676 s0.0294 s0.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.

OperationSamplesMeanMedianRange
One-time real template creation + migration11.218 s——
Clone database creation50.0310 s0.0279 s0.0270–0.0365 s
Clone database drop50.0690 s0.0235 s0.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 ​

  • PostgresDatabaseTestCase now checks APP_ENV=testing, PostgreSQL, and the exact disposable database name elec_parts_test. It runs central migrate:fresh --force once per PHPUnit process. Before every later database-backed test it truncates all public tables except migrations, with RESTART IDENTITY CASCADE. This remains outside a transaction, so independent connections observe committed data and afterCommit callbacks retain their normal behavior.
  • Ordinary PostgresTenantTestCase tests clone a tenant database from one per-process template. The template itself is built by the real synchronous TenantCreated pipeline. 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 normal Tenant::delete() path, which dispatches the normal database deletion pipeline.
  • TenantSchemaContractsTest and PurchaseRequestAllocationTest::test_fresh_tenant_migrations_create_the_canonical_purchasing_schema explicitly opt out of cloning and continue to run fresh tenant creation and migrations. Provisioning tests that extend PostgresDatabaseTestCase also continue to use the real Tenant::create lifecycle.
  • Template and clone names include a random per-process token and the standard tenant prefix, 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. PostgreSQL CREATE DATABASE and DROP DATABASE remain 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.

RunWall timePHPUnit/JUnit timeTests / assertionsFailures / errors / skippedPeak memoryResult
Before 11,378.116 s1,377.417 s609 / 5,2450 / 0 / 0152 MBPass
Before 21,487.833 s1,486.825 s609 / 5,2450 / 0 / 0152 MBPass
After 1546.332 s545.199 s609 / 5,2460 / 0 / 0154 MBPass
After 2585.510 s584.201 s609 / 5,2460 / 0 / 0154 MBPass

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)TestsBefore meanAfter meanChange
Unit61.509 s1.500 s0.6% lower
Integration333921.583 s388.431 s57.9% lower
Feature270509.030 s174.775 s65.7% lower
Central-base tests114141.863 s88.075 s37.9% lower
Tenant-base tests4801,288.244 s474.324 s63.2% lower
Other tests152.014 s2.300 s14.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.

powershell
$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 methodsBeforeAfter
Mean setup time1.91 s0.22 s
Median setup time2.01 s0.19 s
Mean test-body time0.35 s0.35 s
Median test-body time0.31 s0.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:

OperationSamplesMeanMedianRange
Real template create + migrate, once per process11.274 s——
Per-test fixture setup including owner/org and clone50.216 s0.138 s0.120–0.552 s
Clone operation including tenant row and database comment50.0396 s0.0368 s0.0337–0.0462 s
Tenant initialization50.00049 s0.00029 s0.00026–0.00094 s
Teardown including normal delete pipeline and owner/org cleanup50.126 s0.0413 s0.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 durationTest
21.20 sPostgresStockConcurrencyTest::test_competing_moves_create_one_destination_holding_for_a_lot_and_location
21.16 sPostgresStockConcurrencyTest::test_competing_explicit_stock_moves_serialize_without_a_deadlock
21.10 sPostgresStockConcurrencyTest::test_remove_holding_waits_for_a_move_into_it_then_scraps_the_authoritative_balance
20.96 sPostgresStockConcurrencyTest::test_archiving_a_destination_waits_for_an_in_flight_stock_move
18.97 sDocumentationSeedCommandTest::test_it_builds_a_repeatable_documentation_tenant_without_mutating_an_unrelated_tenant
11.19 sPostgresStockConcurrencyTest::test_competing_build_matching_cannot_double_reserve_a_part
11.16 sPostgresStockConcurrencyTest::test_commit_wins_over_a_stale_draft_delete_and_preserves_the_durable_receipt
11.11 sPostgresStockConcurrencyTest::test_competing_reservations_cannot_exceed_a_holding_available_quantity
11.11 sPostgresStockConcurrencyTest::test_delete_wins_over_draft_commit_without_posting_stock
11.11 sPostgresStockConcurrencyTest::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:

powershell
$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=testing with the project’s configured elec_parts_test database. 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.

ElecParts Documentation