fp/fumocker
Fumocker is a lightweight PHP library for mocking function calls in unit tests. It helps you stub built-in or namespaced functions, isolate code from side effects, and write more predictable tests without introducing heavy testing dependencies.
Fumocker is a dev-only seam for the one thing Laravel's test toolbox can't reach: a raw PHP function called directly inside legacy code. Carbon::setTestNow(), Http::fake(), Storage::fake(), and Bus::fake() cover injected/mockable collaborators — Fumocker covers time(), microtime(), rand(), uniqid(), file_get_contents(), curl_exec() being called inline in a class you can't refactor this sprint.
composer require --dev fp/fumocker
composer.json targets PHP 5.x. On a Laravel 9/10/11 app running PHP 8.1+ Composer will likely refuse to resolve it. Decide up front: run that legacy test suite on a separate PHP 5.6/7.x image, or fork-and-patch the version constraint and own the fork. Discovering this in CI is the worst outcome.Fumocker entry class — register() / unregister(), the entire public surface;Scope classes — these decide which files see the mock, and they're the part you'll misconfigure;tests/ — the fixtures are the only real documentation.use Fumocker\Fumocker;
$fumocker = new Fumocker();
$fumocker->register('time', fn() => 1700000000);
// exercise the code under test
$fumocker->unregister('time');
Confirm you actually need it: check the class under test. If it type-hints a collaborator, or the function call sits behind a contract you can bind in the container, use a normal mock. Fumocker is for the $this->expiresAt < time() line, nothing more.Wrap it in a base TestCase — never call register/unregister inline in tests. Cleanup must survive an assertion failure:
use Fumocker\Fumocker;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class LegacyTestCase extends BaseTestCase
{
protected Fumocker $fumocker;
protected function setUp(): void
{
parent::setUp();
$this->fumocker = new Fumocker();
}
protected function tearDown(): void
{
$this->fumocker->unregister(); // unconditional clear
parent::tearDown();
}
}
Put this in tests/Support/ and extend it only from the tests that genuinely need it. Do not add it to tests/TestCase.php — that spreads the blast radius to your whole suite.
Scope the mock narrowly. The Scope concept exists because a global time() override poisoning the rest of the suite is the default failure mode. Register against the specific class (or class + method) under test, not globally, even when the broad version "works." Narrow scopes make failures local and make it obvious who owns the mock.
Use the callback as the observation point. The callback is where you observe arguments, which is the only way to assert "did it read the right config path":
$paths = [];
$this->fumocker->register('file_get_contents', function ($path) use (&$paths) {
$paths[] = $path;
return '{"ok":true}';
});
$this->assertContains(config_path('legacy.json'), $paths);
Mirror the real function's argument signature exactly (file_get_contents($path, $useIncludePath = false, $context = null)), otherwise Laravel/PHP will hand you arity errors that masquerade as mock failures.
Table-driven time tests instead of one test per date. Re-register between rows inside a single test:
foreach ([[1700000000, 'active'], [1730000000, 'expired']] as [$ts, $status]) {
$this->fumocker->unregister('time');
$this->fumocker->register('time', fn() => $ts);
$this->assertSame($status, app(Subscription::class)->status());
}
This is cheaper than ten test methods and it documents the boundary conditions in one place.
Hybrid with PHPUnit/Laravel mocks. $this->mock(BillingGateway::class) for injected collaborators, Fumocker only for the function that can't be injected. Keep the Fumocker surface to one or two functions per test so the test stays readable. If a test needs four Fumocker registrations, that's a signal to refactor the class under test, not to write more mocks.
Treat it as a migration lever, not a destination. The pattern that pays off: freeze the function, write the failing test, extract a Clock / Randomizer / Filesystem contract, bind it in a service provider, delete the mock. Leave a // @todo extract Clock next to each registration. A growing count of those comments means the seam is quietly becoming permanent debt.
Prefer the Laravel-native fake first, and document why when you don't. Carbon::setTestNow(), Http::fake(), Storage::fake(), Config::set(), Queue::fake() are maintained, opcache-safe, and parallel-test-safe. Fumocker is the exception path for code that bypasses all of them — e.g. a vendored SDK calling curl_exec() directly, or a domain rule using rand().
opcache.enable_cli=0 in phpunit.xml/the CLI env and instantiate the class under test after registering.time() is interceptable; \time() is not. Code sitting in the global namespace is also a problem. This is the number-one cause of "the mock isn't firing" and it's identical to the constraint in php-mock.time() you forgot to unregister makes an unrelated test fail an hour later on someone else's branch. Always unregister() in tearDown, and when debugging a flaky suite, grep -rn "register(" tests/ and verify every call is paired.unregister($fn) first (as in the table-driven pattern above) rather than assuming idempotency.php artisan test --parallel forks processes, and a function mock that assumes a single process either doesn't apply or leaks oddly. Exclude Fumocker tests from parallel runs (@group legacy plus a serial CI job) until you've proven otherwise."fp/fumocker": "dev-master#<sha>" or a committed fork. Budget for the day upstream breaks against a new PHP minor.setUpBeforeClass runs before any mock is possible, and Laravel's RefreshDatabase / DatabaseTransactions hooks wrap around setUp/tearDown. Register inside setUp (or the test body) and clean in tearDown, not in class-level fixtures.register()? (4) is the scope actually covering that file? (5) did a previous test leave a mock registered? Most "broken package" reports are one of those five.php-mock/php-mock-phpunit (better maintained, same namespaced-function technique) or finish the dependency-injection refactor.How can I help you explore Laravel packages today?