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.
1. Install as a dev dependency
composer require --dev dg/bypass-finals
It must never reach production. Keep it in require-dev and deploy with composer install --no-dev; that alone is your first safety net.
2. Enable it once, early
There are two supported entry points. Pick one, not both.
Option A — PHPUnit extension (recommended, v1.6+):
<!-- phpunit.xml -->
<phpunit bootstrap="vendor/autoload.php" ...>
<extensions>
<bootstrap class="DG\BypassFinals\PHPUnitExtension"/>
</extensions>
</phpunit>
On PHPUnit 9, the element is still <extension class="DG\BypassFinals\PHPUnitExtension"/>. The extension self-registers before test classes are loaded and already denies vendor/phpunit (v1.8+) so PHPUnit's own internals are never rewritten.
Option B — custom bootstrap file:
<?php // tests/bootstrap.php
require __DIR__ . '/../vendor/autoload.php';
DG\BypassFinals::enable();
Then set bootstrap="tests/bootstrap.php" in phpunit.xml. This is the route to use with Pest, since tests/Pest.php runs too late — the stream wrapper must be installed before the first include of a final class.
3. First use case
Mock something you previously couldn't:
$gateway = Mockery::mock(PaymentSdk\Client::class); // final class
$this->app->instance(PaymentSdk\Client::class, $gateway);
$gateway->shouldReceive('charge')->once()->andReturn(new ChargeResult(true));
$this->post('/checkout')->assertOk();
If that mock now constructs instead of throwing Class ... is declared final and cannot be mocked, you're done. For a quick health check, DG\BypassFinals::debugInfo() returns the current wrapper/whitelist/cache state — dump it in a failing test.
Narrow the surface with a whitelist. Day-to-day, don't bypass your whole codebase — you want app code to stay honest. Restrict to the vendor packages you actually need to mock:
DG\BypassFinals::enable();
DG\BypassFinals::allowPaths([
__DIR__ . '/../vendor/acme/payments-sdk/*',
]);
DG\BypassFinals::denyPath(__DIR__ . '/../vendor/phpunit');
Smaller scope = less rewriting, faster tests, and final on your own classes keeps its design intent.
Mock a final collaborator behind a container binding. The bypass is about class generation; the wiring is still plain Laravel:
$this->mock(FinalReportGenerator::class, function ($mock) {
$mock->shouldReceive('generate')->andReturn('stub');
});
Partial mocks for final methods. A single final method on an otherwise mockable class is the most common real-world case:
$mock = Mockery::mock(FinalClass::class)->makePartial();
$mock->shouldReceive('finalMethod')->andReturn('faked');
Keep a "with bypass" and "without bypass" split. Give the fast unit suite its own config with no extension, and use bypass only in the integration suite that touches vendor SDKs. That keeps accidental reliance on the rewrite out of your core suite, and gives you a canary when a package stops being final.
Pest:
// tests/Pest.php is too late — use the bootstrap/extension instead.
// Then in a test:
$mock = Mockery::mock(FinalJob::class);
Warm the cache in CI. Rewrites are cached on disk (v1.4+, keyed on settings since v1.7). Point the cache at a CI-cached directory so parallel jobs don't each pay the parse cost:
DG\BypassFinals::setCacheDirectory(__DIR__ . '/../storage/framework/testing/bypass-finals');
Guard against production exposure. Add a cheap assertion in your deploy/CI script, e.g. fail if dg/bypass-finals appears in the production autoloader, or wrap enable() in if (getenv('APP_ENV') === 'testing').
included (by a preload script, an early service provider, or a require in artisan), the wrapper never sees it and the "declared final" error returns. Symptom: bypass works for one class but not another. Fix: enable earlier, or check class_exists(Foo::class, false) before enabling.opcache.enable_cli=1 can serve bytecode compiled from the original file, bypassing the stream wrapper's rewritten content. Set opcache.enable_cli=0 (and disable JIT) in your test environment.readonly is a semantic change, not just a keyword. Bypassing it lets tests write to properties that production code can't. If you only need final removed, call DG\BypassFinals::enable(bypassReadOnly: false); — this is the safer default for new adopters.vendor/phpunit for a reason. If you use the manual bootstrap, add that deny yourself.file:// loads. The package chains to a previously registered file:// wrapper (v1.4+), so tools like vfsStream or PHP-VCR generally coexist — but files loaded from a phar:// or a custom wrapper are untouched. Same for any code that compares realpath() results.file_exists, is_file, and directory opens have had bugs fixed over time (v1.7). Pin a recent minor version; open on a directory used to fail.final implies a contract (e.g. an SDK enforcing immutability), keep one integration test against the unmodified class.Mockery::mock('alias:Foo')) define a new class, so they don't need the bypass — but they conflict with a real class already being loaded. Prefer the bypass + normal class mock in Laravel tests.DG\BypassFinals::debugInfo() (v1.9+) plus phpunit --debug will tell you whether the wrapper is active and which paths matched. Print the whitelist — nine times out of ten the glob is wrong.final classes, that's a design smell; file the refactor (extract an interface, drop final) and remove the entry from allowPaths() when it lands.final const and PHP 8.2 readonly classes are newer surface area than the core final class/final method handling — write one throwaway test per construct you depend on rather than assuming coverage.How can I help you explore Laravel packages today?