DEV Community

Ian Johnson
Ian Johnson

Posted on

Traits to Services: Refactoring for Testability (and for Agents)

The Trait Problem

PHP traits are seductive. You've got some chat notification logic. Four controllers need it. Slap it in a trait, use ChatNotificationTrait, done.

Except now you have:

  • Hidden dependencies — the trait calls $this-> methods that don't exist in the trait itself
  • Invisible coupling — change the trait, break four controllers, good luck figuring out which ones
  • Untestable logic — you can't unit test a trait in isolation because it doesn't exist in isolation
  • Global state smell — traits encourage reaching into the controller's properties

This codebase had six traits doing serious work:

Trait What It Did
ChatNotificationTrait Send chat webhooks
CrmApiTrait CRM sync
OcrScanApiTrait OCR via document scanning API
ConvertApiTrait Document conversion
ExternalApiTrait Third-party API integration
CalculationTrait Domain calculations

Each one was used in multiple controllers. Each one mixed HTTP client logic, business rules, error handling, and configuration into a single use statement. Testing any of it meant testing the entire controller.

The Extraction Plan

I planned all six extractions upfront but executed them one at a time, in separate PRs. Each PR:

  1. Created the contract (interface)
  2. Created the service implementation
  3. Bound the interface in the service provider
  4. Updated all controllers to inject the service instead of using the trait
  5. Ran the full test suite

The trait stayed in the codebase until every consumer was migrated. Then it got deleted. At no point was the application broken.

This is the "make change easy, then make the easy change" principle from Kent Beck. Each extraction was a small, safe step. The tests caught any behavioral changes. The linting caught any structural issues.

Contract-First Design

Every extraction started with an interface:

namespace App\Services\Notifications\Contracts;

interface NotificationInterface
{
    public function sendOrderNotification(Order $order, string $message): void;
    public function sendTicketNotification(Ticket $ticket, string $message): void;
}
Enter fullscreen mode Exit fullscreen mode

Then the implementation:

namespace App\Services\Notifications;

class ChatNotificationService implements NotificationInterface
{
    public function __construct(
        private string $webhookUrl,
        private HttpClient $http,
    ) {}

    public function sendOrderNotification(Order $order, string $message): void
    {
        $this->send($this->formatOrderMessage($order, $message));
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Why contracts? Three reasons:

  1. Testability — you can mock NotificationInterface in tests without caring about chat webhooks
  2. Swappability — when we eventually move from chat webhooks to a different notification channel, the interface stays the same
  3. Boundaries — the interface defines what the service does. The implementation defines how. Consumers only know about the what.

The Extraction Sequence

I did these in a deliberate order, starting with the simplest:

1. ChatNotificationTrait → ChatNotificationService
Simplest extraction. HTTP webhook calls with message formatting. No complex state.

2. CrmApiTrait → CRM service classes
More complex — bulk write API, sync tracking, DTO transformations. But the interface was clean: sync users, sync orders.

3. OcrScanApiTrait → DocumentScanner service
OCR integration. Extracted behind a DocumentScannerInterface so we could swap OCR providers later.

4. ConvertApiTrait → Document conversion services
Document format conversion. Straightforward HTTP client wrapper.

5. ExternalApiTrait → ExternalApiClient service
Third-party API integration. Authentication, request signing, response parsing.

6. CalculationTrait → CalculatorService
The most complex extraction. Domain calculation logic with historical configuration tracking. This one needed a ConfigHistory model to properly separate the calculation from the controller state.

Each one took about a day. The full sequence took about two weeks. At no point was the app broken. Users never noticed.

Behind Enough Abstraction

The key phrase is "behind enough abstraction for things to continue working." When you extract ChatNotificationTrait into ChatNotificationService, the controllers that were using $this->sendChatNotification() now call $this->notificationService->sendOrderNotification().

But you don't change all the controllers at once. You:

  1. Create the service
  2. Bind it in the service provider
  3. Update one controller
  4. Run the tests
  5. Update the next controller
  6. Run the tests again

If something breaks, you know exactly which controller change caused it. Small steps. Fast feedback. Empiricism over dogma.

Why Boundaries Help Agents

Here's the thing I didn't fully appreciate until later: clear boundaries help agents more than documentation.

When I later started using Claude to build features, the agent could look at App\Services\Notifications\Contracts\NotificationInterface and immediately understand:

  • What notification capabilities exist
  • What parameters they take
  • How to use them (inject the interface, call the method)

Compare that to the trait world, where the agent would have to:

  • Find the trait
  • Read the trait to understand what methods it provides
  • Figure out which controller properties the trait depends on
  • Hope it's using the trait correctly

The service interface is self-documenting. The trait is a mystery box.

Architecture is the best documentation for agents. If the code structure is clear, the agent doesn't need instructions. It can read the interfaces and follow the patterns.

The Infrastructure Angle

These extractions also cleaned up how we handle external integrations at the infrastructure level. Each service got its own configuration:

// config/services.php
'crm' => [
    'client_id' => env('CRM_CLIENT_ID'),
    'client_secret' => env('CRM_CLIENT_SECRET'),
    'sync_enabled' => env('CRM_SYNC_ENABLED', false),
    'realtime_sync' => env('CRM_REALTIME_SYNC', true),
    'queue' => env('CRM_SYNC_QUEUE', 'crm'),
],
Enter fullscreen mode Exit fullscreen mode

And jobs that previously lived inside traits got extracted into proper Laravel jobs running on dedicated queues:

// CRM sync runs on its own Redis queue
// so it doesn't block order notifications
QUEUE_CONNECTION=redis
REDIS_QUEUE_DB=1
Enter fullscreen mode Exit fullscreen mode

The queue worker in Docker can be spun up with a profile:

docker compose --profile queue up -d
Enter fullscreen mode Exit fullscreen mode

This means CRM sync can be slow, flaky, or temporarily broken without affecting the rest of the application. The queue retries failed jobs. The dedicated queue means a CRM outage doesn't back up critical notifications.

Separating concerns in the code naturally led to separating concerns in the infrastructure. That's the kind of compound benefit you get from doing the refactoring properly.

The Test Coverage Story

Before the extractions: the controllers were tested, but the trait logic inside them was tested only indirectly. You couldn't test "does the chat message format correctly?" without making an HTTP request to the controller.

After the extractions: each service has its own test. The controller tests mock the service interface. The service tests verify the actual logic.

// Before: testing chat notification meant testing the controller
$this->actingAs($admin)->post('/orders/1/approve')
    ->assertOk(); // ...and hopefully the notification was sent?

// After: test the service directly
$service = new ChatNotificationService($webhookUrl, $mockHttp);
$service->sendOrderNotification($order, 'Approved');
$mockHttp->assertSent(/* ... */);
Enter fullscreen mode Exit fullscreen mode

The test pyramid got healthier. More unit tests for services, fewer fat integration tests for controllers.

The Takeaway

Traits are a code smell when they contain business logic. If you're about to use AI agents on a trait-heavy codebase:

  1. Identify your traits — especially the ones with external HTTP calls, complex logic, or shared state
  2. Extract them behind interfaces — contract-first, one service at a time
  3. Bind the interface in a service provider — so consumers inject the contract, not the implementation
  4. Keep the trait until all consumers are migrated — then delete it
  5. Run the full test suite after every change — this is non-negotiable

The result: clean boundaries, testable services, swappable implementations, and a codebase the agent can actually navigate.

Top comments (0)