Back to Blog
Lesson 37 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 28, 20263 min read

Testing DDD Components: Isolating Domain Logic in Laravel

Master Testing DDD components in Laravel. Learn to mock external services, isolate domain logic, and write reliable PHPUnit tests for your Action classes.

LaravelDDDTestingPHPUnitArchitecturephpbackend

Previously in this course, we explored distributed tracing to visualize complex execution paths in our SaaS platform. While tracing helps us observe production behavior, we need a proactive strategy to ensure our domain logic remains correct as we scale. This lesson focuses on Testing DDD components, specifically how to isolate business rules from infrastructure, allowing us to evolve our architecture without breaking critical features.

The Philosophy of Domain Isolation

In a Domain-Driven Design (DDD) approach, your core business logic—residing in Action classes and Service layers—should be agnostic of the database, the framework, or external APIs.

If your test suite hits a live Stripe API or requires a complex database state to verify a simple calculation, you aren't testing logic; you're testing the environment. To test effectively, we must strictly separate Unit Tests (logic-focused) from Integration Tests (infrastructure-focused).

Testing Action Classes in Isolation

Action classes are the entry points for your domain logic. To test them without triggering side effects, we rely on dependency injection and testing with test doubles.

Consider a ProcessSubscriptionUpgrade action that depends on a PaymentGatewayInterface.

PHP
namespace Domain\Billing\Actions;

use Domain\Billing\Contracts\PaymentGatewayInterface;
use Domain\Billing\DTOs\UpgradeData;

class ProcessSubscriptionUpgrade
{
    public function __construct(
        private PaymentGatewayInterface $gateway
    ) {}

    public function execute(UpgradeData $data): bool
    {
        #6A9955">// Domain logic: validation, calculation
        if ($data->amount <= 0) return false;

        #6A9955">// Infrastructure interaction via interface
        return $this->gateway->charge($data->user, $data->amount);
    }
}

To test this, we don't need a real gateway. We mock the interface.

PHP
public function test_it_successfully_upgrades_subscription()
{
    $gateway = $this->createMock(PaymentGatewayInterface::class);
    $gateway->expects($this->once())
        ->method('charge')
        ->willReturn(true);

    $action = new ProcessSubscriptionUpgrade($gateway);
    $result = $action->execute(new UpgradeData(user: $user, amount: 100));

    $this->assertTrue($result);
}

Mocking External Services

When your domain components depend on external services (like an email provider or a CRM), mocking is non-negotiable. Using Laravel's Mockery integration makes this expressive and type-safe.

StrategyBest ForTrade-off
FakesLaravel-native services (Mail, Queue, Event)Less granular control
MocksThird-party API interfacesRequires strict contract adherence
StubsData-heavy dependenciesDoesn't verify interaction behavior

If you are calling a third-party service, wrap it in a custom interface. This allows you to use Mockery to verify that your domain layer is passing the correct data structures.

Hands-on Exercise: Refining the Billing Context

In our ongoing SaaS project, navigate to app/Domain/Billing. Identify your primary CreateInvoice action.

  1. Create a tests/Unit/Domain/Billing/CreateInvoiceTest.php file.
  2. Identify the dependencies: Does it talk to the TaxCalculator service?
  3. Write a test that mocks the TaxCalculator to return a fixed value, ensuring your CreateInvoice logic correctly adds this tax to the final total.
  4. Assert that the logic holds regardless of the actual tax calculation algorithm.

Common Pitfalls

  • Testing Implementation Details: Don't mock private methods or internal Eloquent calls. If your test breaks every time you rename a column, you are testing database schema, not business logic.
  • Over-Mocking: If you find yourself mocking 10 different services for a single action, your action class likely violates the Single Responsibility Principle. Break it down into smaller, focused actions.
  • Ignoring Integration: Unit tests verify the math, but they don't verify the plumbing. Ensure you have a separate suite of integration tests that verify your Service layer actually connects to your database or message broker correctly.

Recap

Testing DDD components is about creating a clear boundary between "what" your business does and "how" the system executes it. By mocking interfaces and injecting dependencies, you ensure your domain logic is portable, readable, and—most importantly—verifiable without needing a full environment spin-up.

Up next, we will explore Contract Testing to ensure that when our domain services communicate, they stay in sync with their expected interfaces.

Similar Posts