dg/bypass-finals
dg/bypass-finals is a PHP library that removes the final keyword at runtime so you can mock final classes and methods in tests. It works with PHPUnit and other mocking tools, making legacy or third-party code easier to test without changing source.
Architecture fit. The package installs a PHP stream wrapper that tokenizes and rewrites source on include, stripping final and readonly before the class reaches the engine. It sits below the framework, so it is orthogonal to Laravel's container, service providers, and facades — which is both its strength (no production coupling, works with any autoloader) and its limitation (it cannot help us where Laravel's own design is the obstacle). Laravel's first-party code rarely marks classes final, so the real addressable surface is narrow and specific: third-party SDKs with final classes or readonly-promoted properties (payments, auth, telemetry, ERP connectors) and legacy in-house modules we cannot refactor on our timeline. Before committing, we should enumerate the actual classes we need to mock today that we cannot — if the list is short, an adapter at our own boundary is cheaper and permanent; if it is long and vendor-owned, this package is the pragmatic answer.
Integration feasibility. Low engineering cost: a require --dev, one registration line in the test bootstrap or the PHPUnit extension (available since 1.6, made configurable in 1.9, and it pre-denies vendor/phpunit by default so PHPUnit's own internals aren't rewritten). No production source changes, no forked vendor code. Realistic effort is under a day including the CI guardrail; the cost is almost entirely governance, not code.
Technical risk.
allowPaths()/denyPath() since 1.8, setWhitelist() earlier, the PHPUnit extension's defaults) but the safe configuration is opt-in and per-directory, not global.BypassFinals\Loader::register() versus DG\BypassFinals::enable()). Expect engineers to follow stale snippets and lose time; our internal doc must be authoritative, not the README.Rewriter, NativeWrapper) carry no backward-compatibility guarantee, and the project is effectively single-maintainer. Inventory shows 580 stars but 0 dependents — popularity without downstream validation, which tells us little about production hardening.Key questions.
final in code we own.Stack fit. Compatible with Laravel's testing stack as-is: PHPUnit, Pest, and Mockery all resolve classes through standard autoloading, so nothing in the application or test suite needs restructuring. Prefer the PHPUnit extension registration over a hand-rolled bootstrap — it is the maintained path, is configurable, and applies sensible default denials we would otherwise have to reconstruct.
Migration path.
composer require --dev dg/bypass-finals and pin the exact version — no caret range on a single-maintainer package with unstable internals.phpunit.xml as an extension (or in tests/bootstrap.php behind an explicit environment check).composer install --no-dev then scan vendor/, or fail the build if the class autoloads outside the testing environment).allowPaths() per suite/directory rather than enabling globally.Compatibility. Confirm three things before merge: our PHP minor is inside the supported range and readonly handling is understood (only meaningful on 8.1+, and enable(bypassReadOnly: false) lets us opt out if rewriting readonly causes surprises); the extension API matches our PHPUnit major (this changed materially across PHPUnit 9 → 10 → 11); and no other stream wrapper or extension conflicts with the chained-wrapper behavior. Disable OPcache in the test environment to avoid stale or unrewritten bytecode producing environment-dependent flakiness.
Sequencing. Legal/license clearance → spike against the two or three genuinely unmockable classes → measure suite runtime and memory delta → ship scoped via allowPaths() to the affected suites only → document the policy and land the CI guardrail → set an explicit re-evaluation date tied to our next PHP or PHPUnit upgrade. Do not parallelize legal with rollout; the license outcome determines whether steps two onward happen at all.
Maintenance. Blast radius is confined to CI because the dependency is dev-only, but ownership is real: budget one to two days a year for version bumps and break-fix, plus a standing fork contingency. Pin aggressively and treat every PHP or PHPUnit upgrade as a review trigger for this package specifically. If upstream goes quiet during a PHP minor upgrade, CI blocks — that is the concrete cost of the bus factor.
Support. No vendor, no SLA, issue-tracker-only. Debugging requires reading the wrapper source, which is a skill we should concentrate in one or two engineers rather than assume team-wide. Cheap mitigations: surface BypassFinals::debugInfo() in CI failure output and keep a one-page internal runbook covering "mock fails unexpectedly" triage.
Scaling. Token rewriting happens per process with a cache keyed on settings, so cost is bounded: expect single-digit-percent suite slowdown and a memory increase on large suites. Parallel runners multiply the rewrite cost across workers but isolate the wrapper per worker, which is acceptable. Keep allowPaths() tight — an over-broad bypass scales the risk, not just the runtime.
Failure modes.
final class we own gets un-finaled, never bypassed.Ramp-up. Trivial mechanically — one line — but the team-level risk is "tests that lie," so adoption needs a short written policy: test and CI only, scoped by path, never a substitute for refactoring code we control, and never merged without the CI guardrail. That policy plus the guardrail is the difference between a useful testing lever and a slow-accumulating correctness debt.
How can I help you explore Laravel packages today?