stevebauman/unfinalize
Unfinalize is a Composer plugin for Laravel and PHP projects that strips the final keyword from vendor classes, making third-party classes easier to extend, decorate, or mock in tests without editing package source.
composer require stevebauman/unfinalize --dev
The package registers a Composer plugin that hooks into post-autoload-dump, so it runs automatically on the next composer install / composer update / composer dump-autoload. No service provider, no artisan command needed.
Create a unfinalize.php config at your project root and list what you want to rewrite:
<?php
return [
'paths' => [
__DIR__.'/vendor/laravel/framework/src/Illuminate/Foundation/Application.php',
__DIR__.'/vendor/some/sdk/src/Client.php',
],
];
Then run:
composer dump-autoload
The listed files are parsed (nikic/php-parser under the hood) and the final keyword is stripped from the class and its methods in place. Verify by opening the vendor file, or reflect on it:
$r = new ReflectionClass(\Illuminate\Foundation\Application::class);
dump($r->isFinal()); // false
First use case: you need to subclass or Mockery::mock() a vendor class that's marked final — commonly Laravel framework internals, an HTTP SDK client, or a value object from a third-party package. Extend it, bind your subclass in a service provider, and register the path so the mutation survives every composer install.
Surgical, per-class paths. List the exact file you need to extend. Broad directory paths work, but each file is rewritten on every autoload dump, so keep the blast radius tight — cheaper runs, easier review of what changed.
Pair with your override. The tool only removes final; it doesn't wire anything up. The complete pattern is:
// unfinalize.php
'paths' => [__DIR__.'/vendor/acme/sdk/src/HttpClient.php'],
// app/Services/HttpClient.php
class HttpClient extends \Acme\Sdk\HttpClient { /* override */ }
// app/Providers/AppServiceProvider.php
$this->app->bind(\Acme\Sdk\HttpClient::class, \App\Services\HttpClient::class);
Treat the config as committed source. Commit unfinalize.php; never commit the rewritten vendor file. The hook regenerates it, so the config is the change. Code review the config diff, not vendor.
CI / deploy ordering. Because it's a post-autoload-dump hook, it fires during composer install in CI. Ensure autoload-dump is not skipped (--no-scripts will bypass it — don't use that flag if you depend on unfinalizing). After install, run your test suite; the classes you mocked are now mockable.
Production without dev dependencies. If the override is needed at runtime (not just tests), composer install --no-dev won't install the plugin, and vendor stays finalized. Either promote it to a normal require, or run composer dump-autoload in a build stage before dropping dev deps — but the cleanest fix is to move the config path and the dependency to require when the override ships to production. For test-only mocking, --dev is correct.
Compare with cweagans/composer-patches. Patches are the right tool when you need to change behavior; unfinalize is the right tool when you only need to change extensibility. Prefer unfinalize for subclassing, patches for bug fixes — patches are more fragile across package updates.
Idempotency. Running the hook repeatedly is safe; the parser just rewrites an already-unfinalized file. No need to guard the config.
Vendor is regenerated. Any composer update/install that touches the package rewrites the vendor file from source, then the hook re-unfinalizes it. The window between is why you must not rely on manual sed edits — always go through the config.
--no-scripts and COMPOSER_NO_SCRIPTS. Both silently disable the plugin. If mocks suddenly fail in CI with "cannot mock final class," check for these flags first, then confirm the plugin is in composer.lock and not blocked by allow-plugins.
Composer allow-plugins prompt. Composer 2.2+ requires interactive approval for plugins. In CI this hangs or errors. Add to composer.json:
"config": {
"allow-plugins": {
"stevebauman/unfinalize": true
}
}
OPcache and long-running workers. Rewriting a file on disk does not invalidate PHP's opcache or your already-booted workers. After install in a running environment: reload PHP-FPM, restart queue workers, and php artisan octane:reload if you use Octane. Locally, opcache_reset() or a FPM restart fixes "it still says final."
Only final is removed. The tool doesn't widen visibility on private constructors or private methods. If a class has a private constructor and you were hoping to instantiate it, you still need a patch or reflection.
Class-level vs. method-level final. Both are stripped from listed files, but if a final method lives on a parent class in a different file, list that parent file too. Unfinalizing the child doesn't unfinalize inherited methods.
Signature and static-analysis drift. Your IDE, Psalm/PHPStan, and composer dump-autoload --optimize all read the file at different times. After changing config, re-run the dump and re-index the IDE. A stale .phpstorm.meta or precomputed classmap will report "cannot extend final class" even though the file is fixed.
Forking is the more honest alternative. If you're unfinalizing three or more classes from the same package, that's the package telling you it wasn't designed to be extended. Consider a maintained fork or a wrapper/decorator (composition over inheritance) before relying on source rewriting. Unfinalize is a pragmatic stopgap, not an architectural solution — treat it as a debt marker with a comment in the config explaining why.
Debugging checklist. (1) Is the path absolute and correct? (2) Did autoload-dump actually run (check for the plugin's output or -vvv)? (3) grep -n 'final class' vendor/.../File.php. (4) OPcache / worker restart. (5) Composer scripts and allow-plugins.
Extension point to know: the plugin is Composer-driven, so the reliable integration surface is composer.json scripts and the unfinalize.php paths array. There's no Laravel event or Artisan command to hook — if you need conditional unfinalizing (e.g., only in testing), do it by swapping the config file or setting the environment before composer install in CI, not at runtime.
How can I help you explore Laravel packages today?