Articles Snippets Projects

Self-Mocking Actions in Laravel

Building a Testable Action Pattern with Service Container Magic

December 17th ʼ25 2 months ago 18 min 3449 words

You've embraced the Action pattern in your Laravel application. Your codebase is clean, responsibilities are separated, and each action does one thing well. Life is good.

Then you start writing tests.

#[Test]
public function checkout_processes_payment_and_sends_receipt(): void
{
    // Arrange - This setup is getting verbose...
    $paymentMock = Mockery::mock(ProcessPaymentAction::class);
    $this->app->instance(ProcessPaymentAction::class, $paymentMock);
    $paymentMock->shouldReceive('handle')
        ->once()
        ->andReturn($this->createPaymentResult());

    $receiptMock = Mockery::mock(SendPaymentReceiptAction::class);
    $this->app->instance(SendPaymentReceiptAction::class, $receiptMock);
    $receiptMock->shouldReceive('handle')->once();

    // Act
    $this->post('/checkout', $this->orderData);

    // Assert...
}

This works, but it's noisy. Every test needs three lines per mocked action: create mock, bind to container, set expectations. With five actions, that's fifteen lines of boilerplate before you even start testing.

What if you could write this instead?

#[Test]
public function checkout_processes_payment_and_sends_receipt(): void
{
    ProcessPaymentAction::shouldRun()->once()->andReturn($this->createPaymentResult());
    SendPaymentReceiptAction::shouldRun()->with($this->order)->once();

    $this->post('/checkout', $this->orderData);
}

Clean. Readable. Laravel-native.

This pattern isn't magic: it's the same approach Laravel uses for Bus::fake(), Event::fake(), and Queue::fake(). In this article, we'll build a self-mocking Action base class that gives your actions first-class testing support.

The Action Pattern Refresher

Before diving into the mocking solution, let's establish what we're working with. The Action pattern (sometimes called Command pattern) encapsulates a single business operation in its own class:

class ProcessPaymentAction
{
    public function __construct(
        private PaymentGateway $gateway,
        private Logger $logger
    ) {}

    public function handle(Order $order, PaymentMethod $method): PaymentResult
    {
        $this->logger->info("Processing payment for order {$order->id}");

        $result = $this->gateway->charge(
            amount: $order->total,
            method: $method
        );

        if ($result->successful()) {
            $order->markAsPaid($result->transactionId);
        }

        return $result;
    }
}

Actions are typically resolved from the container (to get their dependencies injected) and called via their handle() method. Many teams add static convenience methods:

// Instead of this:
app(ProcessPaymentAction::class)->handle($order, $method);

// You can write this:
ProcessPaymentAction::run($order, $method);

Actions are excellent for organization; each file represents one capability. But when Action A calls Action B calls Action C, testing becomes a dependency injection nightmare. That's where self-mocking comes in.

The Core Concept: Dual-Key Container Binding

The key insight behind this pattern is simple: store mocks under a different container key than the real class.

When you normally mock a class, you replace its binding:

$this->app->instance(PaymentAction::class, $mock);

This works, but it's destructive; you've lost the original binding. If another test in the same process needs the real class, you're in trouble.

Our approach uses a dual-key system:

Container Bindings:
┌─────────────────────────────────┬─────────────────────────────┐
 Key                              Value                       
├─────────────────────────────────┼─────────────────────────────┤
 ProcessPaymentAction::class      Real instance (or binding)  
 Fake:ProcessPaymentAction        Mock instance (when testing)
└─────────────────────────────────┴─────────────────────────────┘

When resolving the action:

  1. Check if Fake:{ClassName} exists

  2. If yes, return the mock

  3. If no, return the real instance

This preserves the original binding, allows clean teardown with clearFake(), and lets multiple tests share the same application instance safely.

Building the Base Action Class

Let's build this step by step. We'll create an abstract Action class that all your actions will extend.

Container Methods

First, the static convenience methods for resolving and running actions:

namespace App\Actions;

use Mockery;
use Mockery\MockInterface;

abstract class Action
{
    /**
     * Resolve the Action from the container.
     */
    public static function make(): static
    {
        return app(static::class);
    }

    /**
     * Resolve and immediately execute the Action.
     */
    public static function run(mixed ...$arguments): mixed
    {
        return static::make()->handle(...$arguments);
    }
}

The static::class ensures late static binding; when you call ProcessPaymentAction::make(), it resolves ProcessPaymentAction, not Action.

Fake Storage Methods

Now the infrastructure for storing and retrieving mocks:

abstract class Action
{
    // ... previous methods ...

    /**
     * Check if this action has been faked.
     */
    public static function isFake(): bool
    {
        return app()->isShared(static::getFakeResolvedInstanceKey());
    }

    /**
     * Remove the fake instance from the container.
     */
    public static function clearFake(): void
    {
        app()->forgetInstance(static::getFakeResolvedInstanceKey());
    }

    /**
     * Store a mock instance in the container.
     */
    protected static function setFakeResolvedInstance(MockInterface $fake): MockInterface
    {
        return app()->instance(static::getFakeResolvedInstanceKey(), $fake);
    }

    /**
     * Retrieve the stored mock instance.
     */
    protected static function getFakeResolvedInstance(): ?MockInterface
    {
        return app(static::getFakeResolvedInstanceKey());
    }

    /**
     * Generate the container key for storing fakes.
     */
    protected static function getFakeResolvedInstanceKey(): string
    {
        return 'Fake:' . static::class;
    }
}

The isShared() method checks if a key exists as a singleton in the container. We use forgetInstance() to clean up; this removes the fake binding without affecting the original class binding.

The Mocking API

Now the public API that makes tests beautiful:

abstract class Action
{
    // ... previous methods ...

    /**
     * Create a mock for this action.
     * Returns existing mock if already faked.
     */
    public static function mock(): MockInterface
    {
        if (static::isFake()) {
            return static::getFakeResolvedInstance();
        }

        $mock = Mockery::mock(static::class);
        $mock->shouldAllowMockingProtectedMethods();

        return static::setFakeResolvedInstance($mock);
    }

    /**
     * Create a spy for this action.
     * Spies record calls but don't set expectations upfront.
     */
    public static function spy(): MockInterface
    {
        if (static::isFake()) {
            return static::getFakeResolvedInstance();
        }

        return static::setFakeResolvedInstance(Mockery::spy(static::class));
    }

    /**
     * Create a partial mock.
     * Real methods are called unless explicitly mocked.
     */
    public static function partialMock(): MockInterface
    {
        return static::mock()->makePartial();
    }

    /**
     * Expect the action's handle() method to be called.
     */
    public static function shouldRun(): Mockery\Expectation
    {
        return static::mock()
            ->shouldReceive('handle')
            ->because(class_basename(static::class) . ' should run but did not.');
    }

    /**
     * Expect the action's handle() method NOT to be called.
     */
    public static function shouldNotRun(): Mockery\Expectation
    {
        return static::mock()
            ->shouldNotReceive('handle')
            ->because(class_basename(static::class) . ' should not run but it did.');
    }

    /**
     * Allow the action to run (spy mode) with optional return value.
     */
    public static function allowToRun(): Mockery\Expectation
    {
        return static::spy()->allows('handle');
    }
}

Notice the ->because() calls; these provide helpful error messages when expectations fail. Instead of Method handle() was not called., you'll see ProcessPaymentAction should run but did not.

The Magic: beforeResolving Hook Deep-Dive

We have a mock storage system, but there's a problem: when code calls ProcessPaymentAction::run(), it resolves via app(static::class), which returns the real instance; not our mock.

We need to intercept the resolution process and swap in the mock when one exists. Laravel's service container provides the perfect hook: beforeResolving().

Understanding beforeResolving

The beforeResolving() callback fires before the container resolves any class:

$this->app->beforeResolving(function ($abstract, $parameters, $app) {
    // $abstract is the class/interface being resolved
    // This runs BEFORE resolution happens
});

This is different from afterResolving() (which fires after) or resolving() (which can modify the instance). We need beforeResolving() because we want to set up an extension before the first resolution.

The Action Manager

We need a manager class to:

  1. Track which actions have been extended (to avoid duplicate extensions)

  2. Set up the container extension when an Action is first resolved

  3. Swap real instances with mocks when fakes exist

namespace App\Actions;

use Illuminate\Contracts\Foundation\Application;

class ActionManager
{
    /** @var array<string, bool> */
    protected array $extended = [];

    public function __construct(
        protected Application $app
    ) {}

    /**
     * Extend an action class to support mock swapping.
     */
    public function extend(string $abstract): void
    {
        // Already extended? Skip.
        if ($this->isExtending($abstract)) {
            return;
        }

        // Not an Action? Skip.
        if (!$this->shouldExtend($abstract)) {
            return;
        }

        // Set up the extension callback
        $this->app->extend($abstract, function ($instance) use ($abstract) {
            // If a fake exists, return it instead of the real instance
            if (is_subclass_of($abstract, Action::class) && $instance::isFake()) {
                return $instance::mock();
            }

            return $instance;
        });

        $this->extended[$abstract] = true;
    }

    public function isExtending(string $abstract): bool
    {
        return isset($this->extended[$abstract]);
    }

    public function shouldExtend(string $abstract): bool
    {
        return is_subclass_of($abstract, Action::class);
    }
}

The extend() method is key here. It registers a callback that Laravel will run after resolving the class but before returning it. This callback checks for a fake and swaps it in.

Performance Consideration

You might worry about beforeResolving being called for every single class resolution. That's a valid concern, but notice our guards:

  1. class_exists($abstract, false) → only real classes, not interfaces or strings

  2. $app->resolved($abstract) → skip already-resolved singletons

  3. shouldExtend() → only Action subclasses get extended

In practice, the overhead is negligible → a few microseconds per resolution.

Wiring It Up: The Service Provider

Now we connect everything in a service provider:

namespace App\Providers;

use App\Actions\Action;
use App\Actions\ActionManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;

class ActionServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Create the manager as a singleton
        $manager = new ActionManager($this->app);
        $this->app->instance(ActionManager::class, $manager);

        // Hook into every class resolution
        $this->app->beforeResolving(
            function ($abstract, $parameters, Application $app) use ($manager): void {
                // Skip non-classes and already-resolved bindings
                if (!class_exists($abstract, false) || $app->resolved($abstract)) {
                    return;
                }

                $manager->extend($abstract);
            }
        );
    }
}

Register this provider in your config/app.php (or it will auto-discover if you've set that up):

'providers' => [
    // ...
    App\Providers\ActionServiceProvider::class,
],

That's it. Every Action subclass now has full mocking support.

Real-World Usage: Payment Processing Example

Let's see this pattern in action with a realistic example.

The Actions

// app/Actions/ProcessPaymentAction.php
class ProcessPaymentAction extends Action
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}

    public function handle(Order $order, PaymentMethod $method): PaymentResult
    {
        $result = $this->gateway->charge($order->total, $method);

        if ($result->successful()) {
            $order->markAsPaid($result->transactionId);

            // Trigger follow-up actions
            SendPaymentReceiptAction::run($order, $result);
            UpdateInventoryAction::run($order);
        }

        return $result;
    }
}

// app/Actions/SendPaymentReceiptAction.php
class SendPaymentReceiptAction extends Action
{
    public function __construct(
        private Mailer $mailer
    ) {}

    public function handle(Order $order, PaymentResult $result): void
    {
        $this->mailer->send(new PaymentReceiptMail($order, $result));
    }
}

// app/Actions/RefundPaymentAction.php
class RefundPaymentAction extends Action
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}

    public function handle(Order $order, string $reason): RefundResult
    {
        $result = $this->gateway->refund($order->transactionId, $order->total);

        if ($result->successful()) {
            $order->markAsRefunded();
            SendRefundNotificationAction::run($order, $reason);
        }

        return $result;
    }
}

Test Examples

class CheckoutTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Clear any fakes from previous tests
        ProcessPaymentAction::clearFake();
        SendPaymentReceiptAction::clearFake();
        UpdateInventoryAction::clearFake();
    }

    #[Test]
    public function successful_checkout_processes_payment_and_sends_receipt(): void
    {
        // Arrange
        $order = Order::factory()->create(['total' => 99.99]);
        $paymentResult = new PaymentResult(successful: true, transactionId: 'txn_123');

        ProcessPaymentAction::shouldRun()
            ->with($order, Mockery::type(PaymentMethod::class))
            ->once()
            ->andReturn($paymentResult);

        SendPaymentReceiptAction::shouldRun()
            ->with($order, $paymentResult)
            ->once();

        UpdateInventoryAction::shouldRun()->once();

        // Act
        $response = $this->postJson('/api/checkout', [
            'order_id' => $order->id,
            'payment_method' => 'card_xxx',
        ]);

        // Assert
        $response->assertOk();
    }

    #[Test]
    public function failed_payment_does_not_send_receipt(): void
    {
        $order = Order::factory()->create();
        $failedResult = new PaymentResult(successful: false, error: 'Card declined');

        ProcessPaymentAction::shouldRun()
            ->once()
            ->andReturn($failedResult);

        // These should NOT be called
        SendPaymentReceiptAction::shouldNotRun();
        UpdateInventoryAction::shouldNotRun();

        $response = $this->postJson('/api/checkout', [
            'order_id' => $order->id,
            'payment_method' => 'card_xxx',
        ]);

        $response->assertStatus(422);
    }

    #[Test]
    public function partial_mock_for_testing_internal_logic(): void
    {
        $order = Order::factory()->create();

        // Only mock the external gateway call, let other logic run
        ProcessPaymentAction::partialMock()
            ->shouldReceive('chargeGateway')
            ->once()
            ->andReturn(new PaymentResult(successful: true, transactionId: 'txn_456'));

        $result = ProcessPaymentAction::run($order, new CreditCard('4111...'));

        $this->assertTrue($result->successful());
        $this->assertEquals('txn_456', $result->transactionId);
    }

    #[Test]
    public function spy_allows_verification_after_the_fact(): void
    {
        $order = Order::factory()->create();

        // Allow the action to run, but spy on it
        SendPaymentReceiptAction::allowToRun();

        // Run the full checkout flow
        ProcessPaymentAction::run($order, new CreditCard('4111...'));

        // Verify the receipt was sent with correct arguments
        SendPaymentReceiptAction::spy()
            ->shouldHaveReceived('handle')
            ->with(
                Mockery::on(fn ($arg) => $arg->id === $order->id),
                Mockery::type(PaymentResult::class)
            )
            ->once();
    }
}

Comparison with Laravel's Built-in Fakes

This pattern isn't invented from scratch; it mirrors how Laravel handles faking for its own services.

┌──────────────────────┬────────────────────────────┬──────────────────────────────┐
 Feature               Bus::fake()                 Our Action Pattern           
├──────────────────────┼────────────────────────────┼──────────────────────────────┤
 Static API            Bus::fake()                 Action::mock()               
 Container Integration│ Swaps binding               Swaps via extend()           
 Expectation Chaining  Bus::assertDispatched()     shouldRun()->once()          
 Spy Support           Bus::assertDispatchedTimes() spy()->shouldHaveReceived() 
 Clear/Restore         Fresh app instance          clearFake()                  
└──────────────────────┴────────────────────────────┴──────────────────────────────┘

Key Similarities

  1. Container-based swapping: Both approaches replace the real implementation at the container level

  2. Fluent assertions: Both provide chainable expectation APIs

  3. Spy/mock modes: Both support "record everything" and "expect specific calls" modes

Key Differences

  1. Scope: Laravel's fakes work at the facade level (all jobs, all events), ours work at the class level (each action independently)

  2. Granularity: You can fake ProcessPaymentAction while letting SendReceiptAction run for real

  3. No global fake: There's no Action::fake() that fakes all actions—by design, you explicitly choose what to mock

Alternative Approaches (And Why This One Wins)

Approach 1: Traditional Mockery Injection

$mock = Mockery::mock(ProcessPaymentAction::class);
$this->app->instance(ProcessPaymentAction::class, $mock);
$mock->shouldReceive('handle')->once()->andReturn($result);

Pros: No base class required, works with any class.
Cons: Three lines per mock, verbose setup, easy to forget container binding.

Approach 2: Interface-Based Mocking

interface ProcessPaymentActionInterface { ... }
class ProcessPaymentAction implements ProcessPaymentActionInterface { ... }

// In test:
$this->mock(ProcessPaymentActionInterface::class)
    ->shouldReceive('handle')
    ->once();

Pros: Laravel's $this->mock() handles binding automatically.
Cons: Requires an interface per action (boilerplate explosion), breaks static ::run() API.

Approach 3: Dependency Injection Only

Pass actions as constructor dependencies instead of calling them statically:

class CheckoutController
{
    public function __construct(
        private ProcessPaymentAction $processPayment,
        private SendReceiptAction $sendReceipt
    ) {}
}

Pros: Pure DI, no static methods.
Cons: Every consumer needs all possible actions injected, calling code becomes verbose.

Why Self-Mocking Wins

  1. Zero boilerplate: One line per mocked action

  2. Self-documenting: ProcessPaymentAction::shouldRun() reads like a specification

  3. Consistent API: Every action works the same way

  4. Laravel-native: Follows patterns established by the framework itself

  5. Opt-in mocking: Real implementations run by default; you explicitly choose what to fake

Best Practices and Gotchas

Always Clear Fakes in setUp()

protected function setUp(): void
{
    parent::setUp();

    ProcessPaymentAction::clearFake();
    SendReceiptAction::clearFake();
}

Or create a test trait that clears all known fakes.

Beware of shouldNotRun() Timing

Mockery's shouldNotReceive() exceptions are thrown at Mockery::close() time (end of test), not when the method is called. This can make debugging confusing. Always check that your "should not run" action actually shouldn't run in your test scenario.

Use because() for Better Error Messages

The base implementation includes ->because() calls, but you can add more context:

SendReceiptAction::shouldRun()
    ->once()
    ->because('A receipt must be sent after successful payment');

Consider a Trait for Multiple Base Classes

If you have multiple base classes (e.g., Action, Query, Command), extract the mocking logic to a trait:

trait Fakeable
{
    public static function mock(): MockInterface { ... }
    public static function spy(): MockInterface { ... }
    // ... etc
}

abstract class Action
{
    use Fakeable;
}

abstract class Query
{
    use Fakeable;
}

Don't Over-Mock

Just because you can mock every action doesn't mean you should. Consider:

  • Mock external dependencies (payment gateways, email services)

  • Let simple internal actions run for real

  • Use partial mocks when you only need to stub one method

Conclusion

Self-mocking actions bring the elegance of Laravel's built-in faking system to your own code. The pattern leverages three powerful but underused Laravel features:

  1. beforeResolving() — Hook into every container resolution

  2. extend() — Modify resolved instances before they're returned

  3. Dual-key binding — Store fakes separately from real bindings

The result is a testing experience that reads like documentation:

ProcessPaymentAction::shouldRun()->once()->andReturn($result);
SendReceiptAction::shouldRun()->with($order)->once();
RefundAction::shouldNotRun();

This pattern scales beautifully. Whether you have 10 actions or 200, the testing API remains consistent. New team members can read tests and understand what's being verified without deciphering mock setup code.

The beforeResolving hook is one of Laravel's hidden gems. Now that you've seen it in action, you'll find other creative uses: lazy service decoration, automatic logging, feature flags at the resolution level.

Build the base class once, and every action you create from now on comes with first-class testing support built in.

Complete Implementation

For reference, here's the complete base Action class:

namespace App\Actions;

use Mockery;
use Mockery\MockInterface;

abstract class Action
{
    // region Container

    public static function make(): static
    {
        return app(static::class);
    }

    public static function run(mixed ...$arguments): mixed
    {
        return static::make()->handle(...$arguments);
    }

    // endregion

    // region Mocking

    public static function mock(): MockInterface
    {
        if (static::isFake()) {
            return static::getFakeResolvedInstance();
        }

        $mock = Mockery::mock(static::class);
        $mock->shouldAllowMockingProtectedMethods();

        return static::setFakeResolvedInstance($mock);
    }

    public static function spy(): MockInterface
    {
        if (static::isFake()) {
            return static::getFakeResolvedInstance();
        }

        return static::setFakeResolvedInstance(Mockery::spy(static::class));
    }

    public static function partialMock(): MockInterface
    {
        return static::mock()->makePartial();
    }

    public static function shouldRun(): Mockery\Expectation
    {
        return static::mock()
            ->shouldReceive('handle')
            ->because(class_basename(static::class) . ' should run but did not.');
    }

    public static function shouldNotRun(): Mockery\Expectation
    {
        return static::mock()
            ->shouldNotReceive('handle')
            ->because(class_basename(static::class) . ' should not run but it did.');
    }

    public static function allowToRun(): Mockery\Expectation
    {
        return static::spy()->allows('handle');
    }

    public static function isFake(): bool
    {
        return app()->isShared(static::getFakeResolvedInstanceKey());
    }

    public static function clearFake(): void
    {
        app()->forgetInstance(static::getFakeResolvedInstanceKey());
    }

    protected static function setFakeResolvedInstance(MockInterface $fake): MockInterface
    {
        return app()->instance(static::getFakeResolvedInstanceKey(), $fake);
    }

    protected static function getFakeResolvedInstance(): ?MockInterface
    {
        return app(static::getFakeResolvedInstanceKey());
    }

    protected static function getFakeResolvedInstanceKey(): string
    {
        return 'Fake:' . static::class;
    }

    // endregion
}

This pattern is inspired by the actual implementation used in production at Tarfin, Europe's leading fintech platform for farmer agri-input financing, processing thousands of farmer transactions with complex state machine workflows.