Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Runnable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. src/ — find the runnable contract/base class and the fake registry (facade or static registry). Those two files are the entire surface area.
  2. tests/ — the canonical examples of faking and asserting. Trust method names here over any README snippet.
  3. Release notes — v1.0.2 is the version to read before you form opinions; it changes container-resolution behavior in a way that matters daily.

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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource