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:
Check if
Fake:{ClassName}existsIf yes, return the mock
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:
Track which actions have been extended (to avoid duplicate extensions)
Set up the container extension when an Action is first resolved
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:
class_exists($abstract, false)→ only real classes, not interfaces or strings$app->resolved($abstract)→ skip already-resolved singletonsshouldExtend()→ onlyActionsubclasses 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
Container-based swapping: Both approaches replace the real implementation at the container level
Fluent assertions: Both provide chainable expectation APIs
Spy/mock modes: Both support "record everything" and "expect specific calls" modes
Key Differences
Scope: Laravel's fakes work at the facade level (all jobs, all events), ours work at the class level (each action independently)
Granularity: You can fake
ProcessPaymentActionwhile lettingSendReceiptActionrun for realNo 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
Zero boilerplate: One line per mocked action
Self-documenting:
ProcessPaymentAction::shouldRun()reads like a specificationConsistent API: Every action works the same way
Laravel-native: Follows patterns established by the framework itself
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:
beforeResolving()— Hook into every container resolutionextend()— Modify resolved instances before they're returnedDual-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.