The Problem With "Just Use a Service Class"
Every Laravel codebase eventually grows a Services/ directory that becomes a graveyard of 800-line classes. The instinct to reach for DDD is right — but most implementations either go too far (full hexagonal ports-and-adapters) or not far enough (renaming UserService to UserAction and calling it done).
This article focuses on three concrete building blocks — Actions, DTOs, and Value Objects — and how to wire them together without inventing a framework inside your framework.
Actions: Single-Responsibility Invokables
An Action is an invokable class that encapsulates one use-case. It is not a controller, not a job, not a service. It is the verb of your domain.
// app/Domain/Billing/Actions/ChargeSubscription.php
final class ChargeSubscription
{
public function __construct(
private readonly PaymentGateway $gateway,
private readonly SubscriptionRepository $subscriptions,
) {}
public function __invoke(ChargeSubscriptionData $data): Receipt
{
$subscription = $this->subscriptions->findOrFail($data->subscriptionId);
$charge = $this->gateway->charge(
amount: $data->amount,
paymentMethod: $subscription->paymentMethod,
);
$subscription->recordCharge($charge);
return Receipt::from($charge);
}
}
Resolve it via the container so dependencies are injected automatically:
// In a controller or Livewire component
app(ChargeSubscription::class)(
new ChargeSubscriptionData(
subscriptionId: $subscription->id,
amount: Money::of(4900, 'USD'),
)
);
The controller stays thin. The action is testable in isolation. No service locator smell.
DTOs: Typed Input Boundaries
A DTO (Data Transfer Object) replaces the raw array or Request object that bleeds into your domain. Use PHP 8.x readonly properties — no library required.
final readonly class ChargeSubscriptionData
{
public function __construct(
public int $subscriptionId,
public Money $amount,
) {}
public static function fromRequest(ChargeRequest $request): self
{
return new self(
subscriptionId: (int) $request->validated('subscription_id'),
amount: Money::of(
(int) $request->validated('amount_cents'),
$request->validated('currency', 'USD'),
),
);
}
}
The fromRequest factory keeps HTTP concerns out of the domain. Your action never touches $request->input(). Swap HTTP for a CLI command or a queued job without changing the action.
Value Objects: Enforce Invariants at Construction
A Value Object is immutable, identified by its value, and self-validating. Money is the classic example, but EmailAddress, Slug, Coordinates, and IpRange are equally useful.
final readonly class EmailAddress
{
public string $value;
public function __construct(string $value)
{
$normalized = mb_strtolower(trim($value));
if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailAddress($normalized);
}
$this->value = $normalized;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
Pair it with a custom Eloquent cast so persistence is transparent:
class EmailAddressCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): EmailAddress
{
return new EmailAddress($value);
}
public function set($model, $key, $value, $attributes): string
{
return $value instanceof EmailAddress ? $value->value : $value;
}
}
Now $user->email is always a valid EmailAddress — no validation scattered across controllers.
Keeping It Lean
The trap is adding layers for their own sake. A few rules that prevent bloat:
- One action per use-case. If two use-cases share 80% of logic, extract a private method or a shared service — don't merge the actions.
- DTOs are dumb. No business logic inside a DTO. If you're tempted, that logic belongs in an Action or Value Object.
- Value Objects are small. If a Value Object needs a database query to validate itself, it is not a Value Object — it is a domain service.
- Skip the interface for every action. Interfaces add value when you have multiple implementations or need to mock at the boundary. For most actions, the concrete class is the interface.
Takeaways
- Actions are invokable, single-purpose, and container-resolved — keep controllers and Livewire components thin.
- DTOs create a typed boundary between HTTP/CLI and your domain; use
readonlyclasses in PHP 8.1+. - Value Objects enforce invariants at construction and pair cleanly with Eloquent custom casts.
- Resist adding abstraction until the pain is real — the goal is clarity, not pattern completeness.