The Crime Scene
53 alias and overload mocks walked into a CI pipeline. None of them came out parallel-safe.
One morning we saw this error in CI:
Mockery\Exception\RuntimeException: Could not load mock
App\Services\PaymentGateway\CreditCard\PaymentProcessor, class already existsTest passes locally. Fails in CI. The classic. But this time, I knew exactly what it was. I just didn't want to admit it.
The problem: Mockery alias and overload mocks.
Across multiple test files, we were using Mockery::mock('alias:...') and Mockery::mock('overload:...') in 53 places. And these mocks weren't compatible with parallel test runners.
Why Alias and Overload Mocks Are the Enemy
Mockery's alias: and overload: prefixes let you mock static methods and class instantiation. Sounds great. But they have serious problems.
Problem 1: Not Parallel-Safe
Both alias and overload mocks globally modify PHP's class autoloader. When two tests run in parallel and try to mock the same class:
class already existsPHP looks at you like you just asked it to divide by zero.
Tests run in parallel by default in CI environments. If you run sequentially locally, you won't notice. It blows up in CI.
But Wait, Doesn't Mockery::close() Fix This?
No. Here's the thing: PHP cannot unload a class once it's been defined. This is a fundamental limitation of the PHP runtime, not a Mockery bug.
When you call Mockery::close(), it does these things:
Verifies that all mock expectations were met
Releases mock object references from memory
Prevents memory leaks between tests
But it cannot remove the fake class definition from PHP's class registry. Once PaymentProcessor is defined (even as a mock), it stays defined until the PHP process terminates.
Test A: Mockery::mock('alias:PaymentProcessor')
→ Fake PaymentProcessor registered in PHP's class registry
Test A ends: Mockery::close()
→ Mock expectations cleared
→ BUT: PaymentProcessor class definition still in memory!
Test B: Mockery::mock('alias:PaymentProcessor')
→ FATAL: "class already exists"This is why alias and overload mocks and parallel testing are fundamentally incompatible—not because Mockery is broken, but because PHP's class system doesn't support class unloading.
Problem 2: The Process Isolation Tax
The only safe way to use alias or overload mocks is with @runInSeparateProcess annotation. But this:
Slows down by 10-100x - Each test spawns a new PHP process
Makes debugging impossible - Breakpoints and
var_dumpdon't workMemory overhead - Each process reloads the framework
Multiplies CI time - Parallel runner can't optimize
Problem 3: Design Smell
If you need alias or overload mocks, your code is tightly coupled to static calls. This is a design problem.
The Pattern to Avoid
Here's the pattern we saw 43 times in our project:
// BAD: Alias mock - not parallel-safe
Mockery::mock('alias:'.PaymentProcessor::class)
->shouldReceive('create')
->with($paymentDTO, $cardNumber)
->once()
->andReturnSelf()
->shouldReceive('withCard')
->andReturnSelf()
->shouldReceive('pay3d')
->andReturn(redirect($url));This code works. Locally. In sequential mode. But when CI's parallel runner kicks in, it explodes.
The core issue? PaymentProcessor::create() is a static factory method. Static calls don't go through Laravel's container. You can't intercept them with $this->mock(). The only way to mock them is with alias or overload mocks, which brings us back to the parallel-unsafe territory.
Why Container Mocking is Parallel-Safe
Before we look at solutions, let's understand why container-based mocking doesn't suffer from the same parallel test issues.
| Alias Mock | Container Mock |
|---|---|---|
What it modifies | PHP Autoloader (global) | Laravel Container (test-scoped) |
Scope | Entire PHP process | Single test instance |
Parallel-safe? | ❌ | ✅ |
When PHPUnit runs tests in parallel, each process gets its own PHP runtime and its own Laravel Application instance. Container bindings are isolated to that instance. Alias mocks, however, modify PHP's class autoloading mechanism—which is shared across the entire process.
The key insight: any solution that routes through the container is parallel-safe. Factory injection, container binding, Facades—they all work because they use the container, not the autoloader.
The Escape Routes
Good news: there are multiple ways out of alias and overload mock hell. Bad news: none of them are free.
Each approach trades off different things:
Approach | Effort | Elegance | Keeps Static Syntax | Parallel-Safe |
|---|---|---|---|---|
Factory Pattern | Low | Medium | ❌ | ✅ |
Facade Pattern | High | High | ✅ | ✅ |
Container Binding | Lowest | Low | ❌ | ✅ |
Let's explore each one.
Option 1: Factory Pattern
The Factory pattern wraps your static calls in an injectable class. Quick to implement, gets the job done.
Pros: Minimal changes, fast to implement, works immediately
Cons: Changes call sites from Class::method() to $this->factory->method()
Step 1: Create a Factory Class
<?php
declare(strict_types=1);
namespace App\Services\PaymentGateway\CreditCard;
use App\Enums\ChannelType;
use App\Services\PaymentGateway\CreditCard\DTOs\PaymentDTO;
class PaymentProcessorFactory
{
/**
* Create a PaymentProcessor instance.
*
* Note: Return type intentionally omitted for Mockery compatibility
* with readonly classes.
*
* @return PaymentProcessor
*/
public function create(PaymentDTO $payment, ?string $cardNumber = null, ?ChannelType $channelType = null)
{
return PaymentProcessor::create($payment, $cardNumber, $channelType);
}
/**
* Get default payment calculator.
*
* @return AbstractPaymentCalculator
*/
public function default()
{
return PaymentProcessor::default();
}
}Step 2: Inject the Factory in Controllers
class OrderPayWithCreditCardController extends Controller
{
public function __construct(
private readonly PaymentProcessorFactory $paymentProcessorFactory
) {}
public function pay3D(Request $request, Order $order): RedirectResponse
{
$paymentDTO = PaymentDTO::fromOrder($order);
// Use factory instead of static call
$payment = $this->creditCardPaymentFactory->create(
$paymentDTO,
$request->card_number
);
return $payment->withCard($cardDTO)->pay3d();
}
}Step 3: Mock the Factory in Tests
#[Test]
public function it_pays_using_credit_card_with_3d(): void
{
// Arrange
$order = Order::factory()->create();
$redirectUrl = 'https://payment.example.com/3d';
// Create mock for PaymentProcessor instance
$mockProcessor = Mockery::mock();
$mockProcessor->shouldReceive('withCard')
->with(Mockery::on(static fn ($args) => $args->is($cardDTO)))
->once()
->andReturnSelf();
$mockProcessor->shouldReceive('withTransactionType')
->with(TransactionType::RETAILER_CASH)
->once()
->andReturnSelf();
$mockProcessor->shouldReceive('pay3d')
->once()
->andReturn(redirect($redirectUrl));
// Mock factory to return our mock instance
$this->mock(PaymentProcessorFactory::class, function (MockInterface $mock) use ($mockProcessor, $order): void {
$mock->shouldReceive('create')
->with(
Mockery::on(function ($payable) use ($order): bool {
$this->assertEquals($order->cash_price, $payable->getPaymentAmount());
return true;
}),
'5555555555555555'
)
->once()
->andReturn($mockProcessor);
});
// Act
$response = $this->postJson(route('orders.pay-3d', $order), $paymentData);
// Assert
$response->assertRedirect($redirectUrl);
}Option 2: Container Binding
If you want to avoid creating factory classes, you can use Laravel's container binding directly. This requires the least amount of new code.
Pros: No new classes, minimal code changes, quick to implement
Cons: Changes call sites from Class::method() to app(Class::class, [...])->method()
Step 1: Add Container Binding in ServiceProvider
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(PaymentProcessor::class, static function ($app, array $params): PaymentProcessor {
return PaymentProcessor::create(
payable: $params['payable'],
cardNumber: $params['cardNumber'] ?? null,
);
});
}Step 2: Replace Static Calls
// Before:
PaymentProcessor::create($payable, $cardNumber)->pay3d();
// After:
app(PaymentProcessor::class, ['payable' => $payable, 'cardNumber' => $cardNumber])->pay3d();Step 3: Mock in Tests
// Create a generic mock (works with readonly classes too)
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('pay3d')
->once()
->andReturn(redirect($url));
// Bind mock to container
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);Option 3: Facade Pattern
What if you really love that static syntax? What if PaymentProcessor::create() feels like home and you don't want to leave?
Laravel Facades let you keep the static-looking syntax while routing everything through the container. The catch? You'll need to rename your existing class.
// Step 1: Rename the original class
class PaymentProcessorService // was: PaymentProcessor
{
public function create(PaymentDTO $payable): self
{
return new self(
gateway: (new PaymentGatewayFactory())->create($payable)
);
}
}
// Step 2: Create the Facade
class PaymentProcessor extends Facade
{
protected static function getFacadeAccessor(): string
{
return PaymentProcessorService::class;
}
}Usage stays the same:
// Production code - unchanged!
PaymentProcessor::create($payable)->withCard($card)->pay3d();
// Test - now parallel-safe
PaymentProcessor::shouldReceive('create')
->once()
->andReturn($mockPayment);
Notice the ::shouldReceive() syntax in tests. This only works because PaymentProcessor is now a Facade. Regular PHP classes don't have this method—it's provided by Laravel's Facade base class. If you try SomeRegularClass::shouldReceive(), you'll get a fatal error.
The Facade approach gives you the best of both worlds: familiar static syntax and container-based testability. But it requires renaming your original class and setting up the Facade infrastructure.
What We Actually Did
So which path did we take? The fastest one. The least invasive one. The one that ships.
We were already knee-deep in a major architectural refactoring. Creating factory classes or setting up Facade infrastructure would have been scope creep.
So we went with Option 2: Container Binding. It required the least amount of new code. We just needed to add a binding in the ServiceProvider and change static calls to container resolution.
Step 1: Add Container Binding
// app/Providers/AppServiceProvider.php
$this->app->bind(PaymentProcessor::class, static function ($app, array $params): PaymentProcessor {
return PaymentProcessor::create(
payable: $params['payable'],
cardNumber: $params['cardNumber'] ?? null,
platformType: $params['platformType'] ?? null
);
});Step 2: Update Call Sites
// Before: Static call, not mockable
PaymentProcessor::create($payable, $cardNumber)->withCard($card)->pay3d();
// After: Container resolution, mockable
app(PaymentProcessor::class, ['payable' => $payable, 'cardNumber' => $cardNumber])
->withCard($card)
->pay3d();Step 3: Update Tests
// Before: Alias mock (not parallel-safe)
Mockery::mock('alias:'.PaymentProcessor::class)
->shouldReceive('create')
->andReturnSelf()
->shouldReceive('withCard')
->andReturnSelf()
->shouldReceive('pay3d')
->andReturn(redirect($url));
// After: Container binding (parallel-safe)
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->once()->andReturnSelf();
$mockPayment->shouldReceive('pay3d')->once()->andReturn(redirect($url));
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);
And here's the thing about technical debt: we acknowledged it. We created a backlog item to migrate to the Facade pattern later. The elegant solution is waiting for a quieter sprint.
The takeaway: Sometimes the right solution isn't the perfect one. It's the one that unblocks your team today while leaving the door open for improvements tomorrow.
Mocking Variations
Different mock setups for different scenarios with container binding:
Payment Table (Single Method After Create)
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('getPaymentTable')
->once()
->andReturn(new PaymentTableDTO([...]));
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);Chained Method Calls
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')
->with(Mockery::on(fn ($card) => $card->number === '5555555555555555'))
->once()
->andReturnSelf();
$mockPayment->shouldReceive('withTransactionType')
->with(TransactionType::RETAILER_CASH)
->once()
->andReturnSelf();
$mockPayment->shouldReceive('pay3d')
->once()
->andReturn(redirect($redirectUrl));
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);Exception Testing
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->once()->andReturnSelf();
$mockPayment->shouldReceive('pay3d')
->once()
->andThrow(new MokaPaymentAmountException('Payment failed'));
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);
$response = $this->postJson(route('orders.pay-3d', $order), $paymentData);
$response->assertUnprocessableEntity();Never-Called Assertions
// Verify payment is never attempted when validation fails
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->never();
$mockPayment->shouldReceive('pay3d')->never();
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);
$response = $this->postJson(route('orders.pay-3d', $ineligibleOrder), $paymentData);
$response->assertUnprocessableEntity();Handling Readonly Classes
PHP 8.2+ readonly classes pose a special challenge for Mockery.
// This fails if PaymentProcessor is readonly
$mock = Mockery::mock(PaymentProcessor::class); // Error!Solution 1: Untyped Mocks
// Don't specify class - create generic mock
$mockProcessor = Mockery::mock(); // Works!
$mockProcessor->shouldReceive('pay3d')->andReturn(...);Solution 2: Remove Return Types from Factory
class PaymentProcessorFactory
{
/**
* Return type omitted for Mockery readonly class compatibility.
*
* @return PaymentProcessor
*/
public function create(PaymentDTO $payable) // No return type!
{
return PaymentProcessor::create($payable);
}
}The Migration Checklist
When migrating from alias or overload mocks to container binding:
Add container binding in ServiceProvider for the class
Replace static calls (
Class::create()) with container resolution (app(Class::class, [...]))Update tests - use
$this->app->bind()instead of alias or overload mockIf readonly class, use
Mockery::mock()without class parameterCheck if static method returns a different class (like
::default()) and add separate bindingRun tests to verify refactoring
Search for
Mockery::mock('alias:andMockery::mock('overload:and repeat
Real-World Impact
Results of our refactoring:
Test File | Mocks Removed |
|---|---|
Feature/OrderPaymentControllerTest | 8 |
Feature/PriceCalculatorControllerTest | 9 |
Feature/ApplicationControllerTest | 11 |
Mobile/OrderPayWithCreditCardControllerTest | 8 |
General/OrderPayWithCreditCardControllerTest | 7 |
ZiraatPaymentProviderTest | 5 |
ZiraatPaymentTableCalculatorTest | 1 |
IPSPriceCalculationJobTest | 1 |
PaymentGatewayExceptionHandlerTest | 3 |
Total | 53 |
Result: All affected tests now run in parallel, no conflicts.
Mockery Best Practices
Beyond alias and overload mocks, here are the fundamental rules of Mockery usage:
1. MockeryPHPUnitIntegration Trait
Add this trait to your base TestCase:
<?php
namespace Tests;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use MockeryPHPUnitIntegration;
// ...
}Why it matters:
Mockery expectations are verified at end of each test
Mock assertion counts integrate with PHPUnit
Mockery closes properly after each test
2. Always Add Call Count Expectations
No call count on mock = no assertion = risky test.
Wrong (Risky Test):
BankCollection::shouldReceive('updatePaymentStatusInfoWithPaymentExpCode')
->with($paymentId, $expCode, PaymentStatusType::MATCHED);
// No call count = no assertion = PHPUnit warningCorrect:
BankCollection::shouldReceive('updatePaymentStatusInfoWithPaymentExpCode')
->once() // This is an assertion
->with($paymentId, $expCode, PaymentStatusType::MATCHED);Call Count Methods:
Method | Usage |
|---|---|
| Must be called exactly 1 time |
| Must be called exactly 2 times |
| Must be called exactly n times |
| Must be called at least n times |
| Must be called at most n times |
| Must never be called |
3. partialMock() vs shouldReceive()
Using shouldReceive() directly on facades is dangerous.
Wrong (Can corrupt application container):
LaravelConfig::shouldReceive('get')
->with('some.config.key')
->once()
->andReturn(true);
Correct:
LaravelConfig::partialMock()
->shouldReceive('get')
->with('some.config.key')
->once()
->andReturn(true);Why partialMock() is better:
Only mocks specified methods
Rest of facade remains functional
Doesn't interfere with application container
Prevents Sushi model caching issues
The Facade vs Class Naming Trap
There's another subtle mock failure mode: when a facade and its underlying class share the same name.
We had a package with this structure:
TarfinLabs\LaravelConfig\LaravelConfig // The class
TarfinLabs\LaravelConfig\Facades\LaravelConfig // The facade
See the problem? When you type LaravelConfig in your IDE, autocomplete shows both. It's easy to pick the wrong one.
Wrong (mocking the class directly):
// BAD: Importing the class, not the facade
use TarfinLabs\LaravelConfig\LaravelConfig;
$this->mock(LaravelConfig::class)
->shouldReceive('get')
->once()
->andReturn('value');
// Mock is set up... but facade calls bypass it entirely!The mock doesn't intercept anything because production code uses the facade, not the class directly.
Correct:
// GOOD: Using the facade
use TarfinLabs\LaravelConfig\Facades\LaravelConfig;
LaravelConfig::partialMock()
->shouldReceive('get')
->once()
->andReturn('value');The fix: We renamed the class to ConfigManager in v6:
TarfinLabs\LaravelConfig\ConfigManager // The class (renamed)
TarfinLabs\LaravelConfig\Facades\LaravelConfig // The facadeNow when you type LaravelConfig, only the facade appears. Problem solved at the source.
4. Conditional Mocking for DataProviders
When using DataProviders, not every test case runs the same code path.
Wrong (Some datasets fail):
#[DataProvider('applicationStatuses')]
#[Test]
public function suspend_previous_applications(ApplicationStatus $status): void
{
// Mock setup happens every time
BankMock::cancelApplication(true);
// But some statuses don't call Fiba::cancelApplication()!
// InvalidCountException: Expected 1 call, received 0
}
Correct:
#[DataProvider('applicationStatuses')]
#[Test]
public function test_suspend_previous_applications(ApplicationStatus $status): void
{
$statusesThatTriggerCancellation = [
ApplicationStatus::BANK_DECISION_WAITING,
ApplicationStatus::BANK_PIN_WAITING,
];
// Only mock if code path will be taken
if (in_array($status, $statusesThatTriggerCancellation, true)) {
BankMock::cancelApplication(true);
}
// Test logic...
}
5. Don't Mock Unreachable Code
When using Bus::fake() or Queue::fake(), job internals don't execute.
Wrong:
Bus::fake(); // Jobs won't execute
BankMock::storeApplicant(true, [ // This will fail!
'resultState' => StoreApplicantResultType::TH->name,
]);
StoreApplicantJob::dispatch($application);
Bus::assertDispatched(StoreApplicantJob::class);Correct:
Bus::fake();
// BankMock not needed - job doesn't execute
StoreApplicantJob::dispatch($application);
Bus::assertDispatched(StoreApplicantJob::class);
Scenarios where mock is unnecessary:
Bus::fake()- queued jobs don't executeQueue::fake()- queued jobs don't executeEarly return - subsequent code doesn't run
Exception throw - code after exception doesn't run
PHPUnit Mock vs Stub
PHPUnit 10+ separates mock and stub. In PHPUnit 12, a mock without expectations generates a warning.
Type | Purpose | Method |
|---|---|---|
Mock | Verify calls/arguments |
|
Stub | Placeholder/return value |
|
Rules:
If you only need a placeholder, use
createStub()If you need to verify calls, use
createMock()+expects($this->once())With
getMockBuilder()->onlyMethods(...), stub every listed method
Examples:
// Stub - no verification
$property = $this->createStub(DataProperty::class);
$context = $this->createStub(CreationContext::class);
$result = $cast->cast($property, 'value', [], $context);
$this->assertEquals('expected', $result);
// Mock - with verification
$logger = $this->createMock(Logger::class);
$logger->expects($this->once())->method('log')->with('error');
$service->doSomething();
Concrete Test Stubs
If you only need to provide return values, creating a concrete test stub class avoids PHPUnit warnings:
final class StubQueryBuilder extends BaseQueryBuilder
{
public function __construct(private readonly array $filters = [])
{
$this->model = StubModel::class;
}
public function allowedFilters(): array { return $this->filters; }
public function allowedIncludes(): array { return []; }
public function allowedFields(): array { return []; }
public function allowedAppends(): array { return []; }
public function allowedSorts(): array { return []; }
}
Laravel/Mockery vs PHPUnit Mocking
Tool | Stub | Mock | PHPUnit 10+ notice? |
|---|---|---|---|
PHPUnit |
|
| Yes (if no expectations) |
Mockery |
|
| No |
Laravel facade |
|
| No |
Laravel helper |
|
| No |
Quick Picks:
Notifications/Mail/Event tests:
Facade::fake()+assertSent/assertDispatchedExternal services:
$this->mock(Service::class, fn ($m) => $m->shouldReceive('call')->once())Partial behavior:
$this->partialMock(Class::class, fn ($m) => $m->shouldReceive('send')->once())Cast/DTO placeholders (no container):
createStub()
What's Next
In Part 1: The Reckoning, we explored the six deadly sins of flaky tests and why your CI fails at random.
Part 3: The Determinism Principle tackles predictability:
Flaky timestamp comparisons
The five methods of time control
The random value trap
DataProviders running before Laravel boot
Factory patterns and state management
See you there.
The Flaky Test Chronicles is a series documenting what we learned from 300+ commits of test suite cleanup. Remember: alias mocks are forever. Or until the process dies.