directorytree/runnable
A lightweight Laravel package that lets Eloquent models run their own actions. Add the trait, define runnable methods, and invoke reusable model-specific logic in a clean, testable way instead of scattering it across controllers, jobs, and services. Keeps behavior close to the data it acts on.
directorytree/runnable is a thin utility: it gives focused, single-purpose classes ("runnables") a consistent runtime path through Laravel's container, plus a fake/assert layer so you can swap them in tests the way you already do with Bus::fake() and Queue::fake(). With 11 stars, 0 dependents, and a v1.x tag, treat the source as the documentation — the README is thin, so src/ and tests/ are the real API contract.
Install
composer require directorytree/runnable
Confirm the service provider is auto-discovered (composer.json → extra.laravel.providers in the package, and php artisan about or php artisan package:discover output on your side). Check for a publishable config with php artisan vendor:publish --provider="DirectoryTree\Runnable\..." — if nothing publishes, there's nothing to configure, which is the expected case for a package this small.
Where to look first
src/ — find the runnable contract/base class and the fake registry (facade or static registry). Those two files are the entire surface area.tests/ — the canonical examples of faking and asserting. Trust method names here over any README snippet.First use case
Define a class that does exactly one thing, resolve it from the container at the call site, and fake it in a feature test.
namespace App\Actions;
use App\Actions\Contracts\ChargesOrders; // optional, see Gotchas
use App\Support\PaymentGateway;
class ChargeOrder
{
public function __construct(private PaymentGateway $gateway) {}
public function handle(Order $order): Receipt
{
return $this->gateway->charge($order);
}
}
Call site — no new, no service locator sprawl:
// Controller / command / job
public function __invoke(ChargeOrder $chargeOrder, Request $request)
{
$receipt = $chargeOrder->handle($request->order());
return response()->json(['receipt' => $receipt->id]);
}
Feature test:
use DirectoryTree\Runnable\Facades\Runnable; // verify the exact namespace in src/
it('charges the order on checkout', function () {
Runnable::fake([ChargeOrder::class]);
$this->post('/checkout', ['order' => $order->id])->assertOk();
Runnable::assertRan(ChargeOrder::class);
});
If the static entry point differs from the above, mirror whatever tests/ in the package does — that's the authoritative example. Timebox the spike: install, one runnable, one fake, one assertion, roughly 30 minutes.
One runnable per use case. The package rewards the action-class pattern: constructor injection for collaborators, one handle() entry point, a return value. Keep runnables thin — validation and orchestration stay in the request/command; the runnable is the transaction boundary. If a class needs "and then…" in its name, it's two runnables.
Uniform call sites across drivers. The payoff is that the same class is invoked identically from a controller, an Artisan command, a queued job, and the scheduler:
// Artisan
public function handle(ChargeOrder $chargeOrder)
{
$receipt = $chargeOrder->handle(Order::findOrFail($this->argument('order')));
$this->info("Charged: {$receipt->id}");
}
// Job
public function handle(ChargeOrder $chargeOrder): void
{
$chargeOrder->handle($this->order);
}
// Scheduler (routes/console.php)
Schedule::call(fn () => app(ReconcilePayments::class)->handle())->hourly();
Method injection into handle() is the cleanest form — Laravel resolves it, and the container stays the only seam you need to control in tests.
Fake at the integration boundary, not the model layer. The strongest day-to-day use is isolating third-party providers. Wrap the provider in a runnable, fake the runnable in tests, and your feature tests never touch the network:
Runnable::fake([ChargeOrder::class, SendWelcomeEmail::class, SyncToCrm::class]);
$this->post('/onboarding', $payload)->assertRedirect('/dashboard');
Runnable::assertRan(SendWelcomeEmail::class);
Runnable::assertNotRan(SyncToCrm::class);
Then have exactly one small, skipped-by-default integration test that exercises ChargeOrder against the real sandbox. You get fast feature tests and a real fake-drift guard.
Composition — runnables calling runnables. A PlaceOrder runnable that injects ChargeOrder and ReserveInventory is fine and readable; resolve them via constructor injection, not app() inside the method. Faking the parent does not fake the children, so in tests either fake the parent only (unit-ish) or fake the full set (integration-ish). Be deliberate about which.
Interface bindings vs concrete fakes. If you bind an interface in a service provider (ChargesOrders::class => StripeCharges::class), make sure you know whether the fake registry keys off the abstract or the concrete. Faking ChargesOrders::class while your code type-hints the concrete StripeCharges is a classic silent miss. Pick one convention — I'd recommend faking concrete classes and reserving interfaces for genuine swappability.
A test base class or Pest helper. Centralize the reset so fake state can't cross tests:
// tests/Pest.php
afterEach(fn () => Runnable::fake([])); // or whatever the reset/clear method is
Or a InteractsWithRunnables trait in tests/Concerns. Either way, one place, not 40 beforeEach blocks.
Migration path for existing code. Don't rewrite. Wrap: make a runnable that delegates to the legacy service, convert call sites one at a time, and only then hollow out the legacy class. This keeps the blast radius small and gives you a natural A/B on test runtime.
Code-review checklist. Runnables live in app/Actions (or app/Runnables — pick once), have one public method, no facades inside, no app() calls inside, and a return type. Enforce it in review, not with tooling; the package gives you no enforcement.
Pin ^1.0.2 minimum. v1.0.2 fixed a real papercut: fakes were bypassed when a class was resolved with explicit constructor parameters — app()->make(Action::class, ['dependency' => $dep]). That pattern is everywhere in Laravel code, so on 1.0.1 you'd get a passing fake assertion while the real class ran. Add a regression test for it in your own suite:
Runnable::fake([ChargeOrder::class]);
app()->make(ChargeOrder::class, ['gateway' => $fakeGateway]);
Runnable::assertRan(ChargeOrder::class);
Fakes only apply to container resolution. new ChargeOrder(...) and ChargeOrder::handle() static calls bypass everything. This is the number-one source of "the fake didn't work." Grep for new on your action classes during the pilot.
Fake leakage across tests. A fake registered in one test persists into the next unless something resets it — especially with a long-lived registry. Under phpunit --parallel this becomes nondeterministic. Reset in a global teardown hook and treat the reset call itself as required boilerplate in your project's test template.
Double-faking. If you also $this->mock(ChargeOrder::class) on top of Runnable::fake(), you get two competing bindings and confusing failures. Pick one style per test; I'd delete the Mockery usage for anything registered as a runnable.
Fake behavior drift is the real long-term risk. The fake says "ran," not "ran correctly." Runnables that return values need a stub path — check whether the fake supports Runnable::fake([X::class => fn () => $receipt])-style returns, or whether you must use andReturn on a mock. If returns aren't supported cleanly, keep return-heavy runnables unfaked and assert on their output instead.
Don't accidentally ship fakes. Runnable::fake() in a service provider, a bootstrap file, or a console command that gets invoked in a non-test context is a production incident waiting to happen. Keep fake calls inside tests/ only, and ideally guard with an app()->runningUnitTests() assertion in any helper that wraps them.
Long-running workers and Octane. The fake registry is container/process scoped. In queue workers or Octane the container persists across "requests" — verify that fakes don't survive between jobs if you ever register them outside a pure test process, and confirm resolution behavior under Octane before betting on it.
Debugging resolution. When an assertion fails, check what's actually bound before blaming the package:
app()->bound(ChargeOrder::class);
app()->getBindings(); // or inspect the specific abstract
app()->make(ChargeOrder::class)::class; // is it the fake or the real one?
php artisan tinker is the fastest place to do this.
Mockery interop. The package resolves through the container, so $this->instance(ChargeOrder::class, $mock) still works and composes with it. If an assertion helper doesn't exist for what you need, drop to Mockery::spy() + shouldHaveReceived('handle') and keep moving — don't fight the abstraction.
Vendor risk is the honest caveat. Zero dependents, one maintainer, no SLA. Mitigate cheaply: pin an exact version, wrap the import behind your own base class or contract in app/Support/, and note in the ADR that removal is a find-and-replace on the use statement. Also verify the support matrix (PHP/Laravel constraints in composer.json) and sanity-check the future-dated 2026-10-02 release entry before you rely on the tag history.
Don't force it. Runnables earn their keep on side-effecting, integration-touching, or cross-driver tasks. Wrapping pure domain logic, value objects, or anything with a single call site in a runnable is ceremony — the package buys you a test seam, and if there's nothing to seam out, skip it.
How can I help you explore Laravel packages today?