Most Laravel applications do not become difficult to maintain because somebody made one obviously terrible architectural decision. They get there through dozens of small decisions that looked completely reasonable at the time.
A controller grows beyond a comfortable size, so part of its logic moves into an Eloquent model. Later, the application needs to send a notification after an order ships. Then inventory must be checked through an external API. Accounting requests an ERP integration, and marketing wants loyalty points awarded after dispatch.
Each change adds only a few lines. Since the Order model is already available wherever the feature is being implemented, adding one more method feels natural:
$order->ship();PHPIt is concise, expressive, and pleasantly object-oriented. Six months later, however, that innocent method may be updating several tables, calling two APIs, dispatching events, sending notifications, and deciding whether the entire operation is allowed.
The model has quietly become the place where the application runs its business workflows.
That is the real problem. It is not that the model is “fat.” It is that persistence, domain decisions, and application orchestration have been mixed together until nobody can change one without understanding all three.
Previous article in this category: https://codecraftdiary.com/2026/07/06/specification-pattern-in-laravel/
Business Logic in Eloquent Models Is Not Automatically Wrong
The common advice to remove all business logic from Laravel models goes too far.
Eloquent follows the Active Record pattern. Martin Fowler describes Active Record as an object that represents a database row, encapsulates database access, and adds domain logic related to that data. In other words, an object containing both state and behavior is not an architectural mistake by itself.
A model should be allowed to answer questions about itself:
class Order extends Model
{
protected function casts(): array
{
return [
'paid_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function isPaid(): bool
{
return $this->paid_at !== null;
}
public function isExpired(): bool
{
return $this->expires_at?->isPast() ?? false;
}
public function hasShippingAddress(): bool
{
return filled($this->shipping_address);
}
}PHPThese methods are cohesive. They depend only on the order’s own state and make that state easier to understand. Relationships, casts, scopes, accessors, mutators, and similar data-oriented behavior are also natural Eloquent responsibilities; Laravel itself defines relationships as model methods and provides casts specifically for transforming model attributes.
The useful distinction is therefore not business logic versus no business logic.
It is describing state versus coordinating work.
When a Model Starts Coordinating the Application
Consider a more ambitious implementation of ship():
class Order extends Model
{
public function ship(): void
{
if (! $this->isPaid() || ! $this->hasShippingAddress()) {
throw new DomainException('The order cannot be shipped.');
}
$inventoryResponse = Http::get(
"https://inventory.internal/orders/{$this->id}"
);
if (! $inventoryResponse->json('available')) {
throw new DomainException('The items are not available.');
}
DB::transaction(function (): void {
$this->update(['status' => 'shipped']);
$this->items()->decrement('reserved_quantity');
});
Http::post('https://shipping.example.com/dispatch', [
'order_id' => $this->id,
]);
$this->customer->notify(
new OrderShippedNotification($this)
);
event(new OrderShipped($this));
}
}PHPThe public API still looks elegant, but the implementation hides a surprising amount of machinery. Calling one method now performs HTTP requests, changes persistent state, updates inventory, sends a notification, and publishes an event.
That creates several practical problems.
First, its dependencies are invisible. Nothing about $order->ship() tells the caller that a shipping provider and inventory system must be available. Second, testing one business rule requires faking HTTP requests, notifications, events, and probably a database. Third, every future shipping requirement will be attracted to the same method because it already appears to be the official home of the workflow.
The class may still be called Order, but it is acting as an application service.
Give the Workflow an Explicit Home
The solution does not require hexagonal architecture, CQRS, repositories for every model, or an interface in front of every class. For many Laravel applications, one focused workflow class is enough.
Whether your team calls it an action, command, handler, or service matters less than giving it a clear responsibility.
namespace App\Actions;
use App\Models\Order;
use App\Notifications\OrderShippedNotification;
use App\Services\InventoryService;
use App\Services\ShippingGateway;
use Illuminate\Support\Facades\DB;
final class ShipOrder
{
public function __construct(
private InventoryService $inventory,
private ShippingGateway $shipping,
) {
}
public function handle(Order $order): void
{
$this->ensureOrderCanBeShipped($order);
$shipment = $this->shipping->dispatch($order);
DB::transaction(function () use ($order, $shipment): void {
$order->update([
'status' => 'shipped',
'shipment_reference' => $shipment->reference,
'shipped_at' => now(),
]);
});
$order->customer->notify(
new OrderShippedNotification($order)
);
}
private function ensureOrderCanBeShipped(Order $order): void
{
if (! $order->isPaid()) {
throw new CannotShipOrder('The order has not been paid.');
}
if (! $order->hasShippingAddress()) {
throw new CannotShipOrder('The shipping address is missing.');
}
if (! $this->inventory->isAvailableFor($order)) {
throw new CannotShipOrder('One or more items are unavailable.');
}
}
}PHPThe controller remains small without forcing the workflow into the model:
final class ShipOrderController
{
public function __invoke(
Order $order,
ShipOrder $shipOrder,
): JsonResponse {
$shipOrder->handle($order);
return response()->json([
'message' => 'Order shipped successfully.',
]);
}
}PHPThis is not better merely because the code lives in another file. It is better because the dependencies and responsibility are now explicit. InventoryService answers an inventory question. ShippingGateway communicates with the shipping provider. ShipOrder coordinates the use case. The Order model describes and persists order state.
Laravel’s transaction helper also gives the persistence portion a clear boundary and automatically rolls it back when an exception is thrown.
Avoid Building a Fat Action Instead
Extracting a workflow does not give it permission to become another dumping ground.
If ShipOrder eventually contains payment processing, PDF generation, ERP payload construction, low-level HTTP calls, and hundreds of lines of unrelated calculations, the architecture has not improved much. The oversized model has simply been replaced by an oversized action.
A workflow class should coordinate collaborators rather than implement every collaborator itself. External API details belong behind focused clients or gateways. Complicated calculations may deserve dedicated domain objects. Slow, retryable work can be handed to queued jobs; Laravel provides queues specifically for deferring work across several supported backends.
Real integrations also introduce failure scenarios that a short example cannot solve completely. A shipping provider might accept a request just before the database update fails. Production systems may therefore need idempotency keys, retry policies, an outbox, or an intermediate status such as shipping_pending. The important point is that these concerns become easier to see once the workflow has an explicit home.
A Rule That Is Actually Useful
When deciding where a new method belongs, ask one question:
Does this method describe the object, or does it coordinate the application?
These methods usually fit naturally on a model:
$order->isPaid();
$order->isExpired();
$product->isDiscontinued();
$user->hasVerifiedEmail();PHPThese operations deserve closer inspection:
$order->ship();
$order->issueRefund();
$invoice->sendToAccounting();
$product->synchronizeWithErp();PHPA method name alone does not decide the answer. An Order::ship() method that only performs a valid local state transition may be perfectly reasonable. An Order::ship() method that calls APIs, sends emails, updates unrelated records, and queues jobs is a hidden workflow.
Good Laravel architecture is not about producing the largest possible number of small classes. It is about making the next change obvious.
Your Eloquent models can contain meaningful behavior. They should understand their relationships, attributes, and state. What they should not quietly become is the control room for the rest of the application.
When workflows have explicit homes and dependencies have clear boundaries, controllers stay readable, models remain coherent, and future developers do not need to reverse-engineer half the codebase to discover what happens when an order ships.
The models were never the real enemy.
The hidden workflows were.

