When a Laravel application is young, it is normal for business rules to live close to the feature being built. A controller checks a condition. A model mutator calculates a value. A queued job quietly decides whether a customer should be billed. It feels fast because everything is nearby.
Then the product grows.
The same rule appears in three controllers. A payment edge case is handled in a listener but not in the admin panel. A developer changes one workflow and accidentally breaks another. Releases get slower because nobody fully trusts the system.
That is tangled business logic, and in a serious Laravel app, it becomes one of the most expensive forms of technical debt.
Untangling it does not mean rewriting everything or forcing your Laravel app into an abstract enterprise architecture. It means making the important rules visible, testable, and located where future developers can find them. The goal is not architectural purity. The goal is a system that can change without chaos.
Business logic is the code that represents how your organization actually works. It is not simply “code that is not UI.” It is the rules, workflows, calculations, permissions, and decisions that make the application valuable.
In a Laravel app, business logic often includes:
Whether an invoice can be approved
How a subscription changes when a customer upgrades mid-cycle
Which users can access a tenant’s data
When an order should be locked from editing
How a commission, score, fee, or discount is calculated
What happens after a status changes
Which external system owns the source of truth for a record
Laravel gives you many good places to put code: controllers, models, form requests, policies, jobs, events, listeners, commands, observers, services, actions, and more. That flexibility is one of Laravel’s strengths, but it also means teams need judgment.
A tangled app usually did not get that way because the team was careless. It got that way because early decisions kept working long enough to become load-bearing.
You do not need a full architecture audit to notice the smell. The app will usually tell you.
Symptom | What it usually means | Why it matters |
|---|---|---|
Controllers are hundreds of lines long | HTTP handling and business workflows are mixed together | Every UI or API change risks breaking core behavior |
The same condition appears in several places | A business rule has no single home | One path will eventually drift from the others |
Models contain unrelated methods | The model has become a dumping ground | Developers cannot tell which methods are true domain rules |
Jobs and listeners make major decisions | Async plumbing is hiding business behavior | Critical rules become hard to trace and test |
Tests require huge setup for small rules | The logic is too coupled to framework or database concerns | Refactoring becomes slow and risky |
Nobody knows where to add a new rule | The architecture lacks conventions | Each feature increases inconsistency |
If these problems sound familiar, they are closely related to the Laravel architecture mistakes that hurt SaaS teams, especially scattered business logic, weak boundaries, and unclear ownership of decisions.
The good news is that you can usually improve the situation incrementally. The bad news is that “just move code into services” is not enough.
Before moving code, understand what the code is trying to protect.
A common mistake is to open the largest controller and immediately start extracting methods. That can make the file shorter without making the system clearer. You have moved the mess, but not untangled it.
Instead, pick one business workflow and map it from the outside in. For example:
User submits an approval request
App validates the request shape
App checks whether the user can approve this record
App checks whether the record is in an approvable state
App updates the record inside a transaction
App records an audit event
App notifies the next person
App syncs with an external system
That workflow map tells you what kinds of logic exist. Some belong in Laravel-native structures. Some belong in domain objects. Some belong behind integration boundaries. Some may need to be asynchronous.
This is where technical and product thinking need to meet. The question is not only “where should this code live?” It is also “what business promise does this code enforce?”
Laravel applications become easier to reason about when each layer has a clear job. You do not need to introduce a heavy architecture to get there. You just need to stop making every class responsible for everything.
A practical division often looks like this:
Concern | Good Laravel home | What to avoid |
|---|---|---|
Request shape and basic input validation | Form requests | Putting business workflows in validation rules |
User permissions | Policies or gates | Repeating |
Business workflow orchestration | Action, use case, or application service classes | Huge controllers or generic service classes |
Domain invariants | Models, value objects, enums, or domain methods | Allowing invalid states to be created casually |
Side effects | Events, listeners, jobs, notifications | Hiding core business decisions in async handlers |
Third-party communication | Client, gateway, or integration service classes | Calling vendor SDKs directly throughout the app |
Laravel’s own documentation on authorization policies is a good example of this principle. Authorization is important business behavior, but Laravel gives it a dedicated place so it does not have to be scattered across controllers and views.
The same idea applies to your application-specific rules. Give important decisions a home.
For many Laravel teams, the most useful first step is introducing action classes, sometimes called use cases. These classes represent a specific business operation in plain language.
Good names sound like things the business does:
ApproveInvoice
CancelSubscription
InviteTeamMember
PublishCourseModule
RecalculateCommission
TransferOwnership
Weak names sound like vague technical containers:
InvoiceService
UserHelper
SubscriptionManager
AdminUtility
A generic service class can be fine when it has a cohesive responsibility, but many Laravel apps end up with enormous SomethingService classes that become the new junk drawer. An action class is harder to abuse because its purpose is narrow.
Here is a simplified example of tangled controller logic:
public function approve(Request $request, Invoice $invoice)
{
if (! $request->user()->is_manager) {
abort(403);
}
if ($invoice->status !== 'pending') {
throw ValidationException::withMessages([
'invoice' => 'Only pending invoices can be approved.',
]);
}
DB::transaction(function () use ($invoice, $request) {
$invoice->status = 'approved';
$invoice->approved_by = $request->user()->id;
$invoice->approved_at = now();
$invoice->save();
Activity::create([
'subject_id' => $invoice->id,
'message' => 'Invoice approved.',
]);
});
Mail::to($invoice->customer_email)->send(new InvoiceApprovedMail($invoice));
return redirect()->back();
}
The problem is not that this code is impossible to read. The problem is that HTTP handling, authorization, state validation, persistence, audit logging, and notification are all fused together.
A better version gives the workflow a name:
public function approve(Invoice $invoice, ApproveInvoice $approveInvoice)
{
$this->authorize('approve', $invoice);
$approveInvoice->handle($invoice, auth()->user());
return redirect()->back();
}
Then the action owns the workflow:
final class ApproveInvoice
{
public function handle(Invoice $invoice, User $approver): void
{
if (! $invoice->isPending()) {
throw new InvoiceCannotBeApproved('Only pending invoices can be approved.');
}
DB::transaction(function () use ($invoice, $approver) {
$invoice->approve($approver);
Activity::record($invoice, 'Invoice approved.');
});
InvoiceApproved::dispatch($invoice->id);
}
}
Now the controller is responsible for HTTP flow, the policy is responsible for permission, the action is responsible for orchestration, and the model can own the state transition.
That separation makes the business operation easier to test, reuse, and change.
Some teams respond to fat controllers by pushing everything into Eloquent models. That can be an improvement, but only to a point.
Eloquent models are a good place for behavior that is truly about the entity. For example, an Invoice model can reasonably know whether it is pending, whether it is overdue, or how to mark itself as approved.
public function approve(User $approver): void
{
$this->forceFill([
'status' => InvoiceStatus::Approved,
'approved_by' => $approver->id,
'approved_at' => now(),
])->save();
}
But an Invoice model probably should not know how to send emails, call QuickBooks, update a CRM, notify Slack, and recalculate an account health score. Those are workflows and side effects involving multiple concepts.
A useful rule of thumb: if the method only needs the model’s own data and enforces an invariant of that model, it may belong on the model. If it coordinates several models, external systems, or user intentions, it probably belongs in an action or service.
Much of the tangled business logic stems from weak modeling. If every status is a string, every amount is a float, and every permission is a role name checked inline, your rules will spread because the code has no shared vocabulary.
Laravel and modern PHP give you simple tools to make rules more explicit.
Enums are useful for known states:
enum InvoiceStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Approved = 'approved';
case Paid = 'paid';
case Void = 'void';
}
Value objects can help when a concept has rules of its own, such as money, date ranges, addresses, measurements, or scoring ranges. You do not need to turn every primitive into an object, but when a value keeps attracting validation and formatting logic, that is a sign it may deserve a name.
State transitions are another common source of drift. If a subscription can move from trialing to active, past_due, canceled, and expired, the transition rules should not be scattered across controllers and webhook handlers. Give those transitions a central place, whether that is a state machine package, a dedicated domain class, or clear methods on the model.
The point is not to make the code clever. The point is to make invalid behavior harder to write by accident.
Business logic gets especially tangled when third-party integrations are mixed directly into workflows. Stripe calls in controllers, QuickBooks SDK usage in listeners, Google API logic in jobs, and webhook interpretation in models will eventually create a system that is hard to test and harder to change.
External systems should sit behind a boundary your application controls.
Instead of letting the rest of the app know every detail of a vendor SDK, create a small client or gateway that speaks your application’s language. For example, your business workflow should say “create a customer billing profile,” not “construct this vendor-specific payload with these API fields.”
That boundary gives you several advantages:
You can fake the integration in tests
You can handle retries and vendor errors consistently
You can change vendors or API versions with less fallout
You can keep vendor concepts from leaking into your domain model
For more detail on this specific problem, Ravenna has a deeper guide on how to de-risk third-party integrations in Laravel.
The key point for business logic is simple: integrations should support the workflow, not define it.
Laravel events and listeners are powerful, but they can also make business behavior difficult to trace.
Events are a good fit when something meaningful has happened, and other parts of the system may need to react. InvoiceApproved, SubscriptionCanceled, or CourseCompleted are useful domain events because they describe business facts.
But events become dangerous when they hide required steps in a workflow. If approving an invoice must always create an audit record inside the same transaction, that probably should not be an optional listener running somewhere else. If a listener failure would leave the system in an invalid state, the work may belong in the main action or transaction instead.
A practical distinction helps:
Work type | Better location | Example |
|---|---|---|
Required state changes | Main action or transaction | Mark invoice approved, store approver, lock edits |
Required consistency records | Main action or transaction | Create audit entry required for compliance |
Optional reactions | Event listener or queued job | Send email, notify Slack, update search index |
Slow external calls | Queued job behind integration boundary | Sync approved invoice to accounting system |
Events should make the system more modular, not more mysterious.
Untangling business logic without tests is risky. You are changing where decisions live, and it is easy to change behavior unintentionally.
You do not need perfect coverage before starting. You need enough characterization tests to describe what the system currently does. In legacy or messy areas, those tests may initially capture behavior you do not love. That is acceptable. First preserve the known behavior, then improve it deliberately.
Useful tests often focus on business outcomes:
A pending invoice can be approved by an authorized manager
A draft invoice cannot be approved
Approval records the approver and timestamp
Approval emits the expected event
Unauthorized users cannot approve the invoice
A failed approval does not leave partial updates behind
These tests should not care whether the rule lives in a controller, action, or model. They should care about the promise the system makes.
Once those tests are in place, refactoring becomes less like surgery in the dark and more like controlled renovation.
The safest way to untangle business logic is usually by workflow, not by layer.
A layer-based refactor says, “Let’s clean up all controllers,” then “Let’s clean up all models,” then “Let’s introduce services everywhere.” That sounds organized, but it often creates wide, risky pull requests with little business value until the end.
A vertical refactor says, “Let’s clean up invoice approval from request to database to side effects.” It touches fewer concepts, yields a clearer result, and provides the team with a pattern to repeat.
A good vertical slice might include:
Move request validation into a form request
Move permission checks into a policy
Introduce an ApproveInvoice action
Move state transition behavior onto the model
Put required writes inside a transaction
Dispatch a domain event after the core state change
Add focused tests around the workflow
After one or two successful slices, the team can agree on conventions and apply them elsewhere. That is how a Laravel app becomes more maintainable without a risky rewrite.
If the system is already fragile enough that even small changes feel unsafe, it may be worth evaluating whether the situation calls for focused refactoring or a broader rebuild decision. Ravenna’s guide on when to rebuild vs refactor a Laravel application can help frame that conversation.
There is a real danger on the other side of messy Laravel code: over-architecture.
Not every app needs domain-driven design, command buses, repositories, event sourcing, or a deeply layered directory structure. Those patterns can be useful in the right context, but they can also bury straightforward Laravel code under abstractions that do not earn their keep.
A healthier standard is this: introduce structure when it reduces risk, improves clarity, or makes change easier.
For a small CRUD feature, a controller, form request, policy, and Eloquent model may be perfectly fine. For a revenue-critical billing workflow with edge cases, retries, permissions, audit trails, and external integrations, a named action with clear boundaries is probably worth it.
The architecture should match the cost of being wrong.
Untangling business logic is not only a code exercise. It is a team habit.
If every developer has a different opinion about where rules belong, the application will slowly drift back into inconsistency. You do not need a 40-page architecture document, but you do need shared conventions.
Document the basics in plain language:
Controllers handle HTTP and delegate business workflows
Form requests validate request shape, not entire business processes
Policies own authorization decisions
Actions represent named business operations
Models own entity-level behavior and invariants
Jobs handle delayed or slow work
Integration clients isolate vendor-specific APIs
Events describe business facts that already happened
These conventions should be specific enough to guide decisions and flexible enough to handle exceptions. The goal is not to win arguments about patterns. The goal is to help the next developer put the next rule in a predictable place.
If you are looking at a mature Laravel app and wondering where to begin, start with the workflow that changes often, breaks often, or carries the most business risk.
Then work through this sequence:
Name the workflow clearly: Choose one operation, such as approving an invoice, onboarding a customer, or publishing a course.
Write characterization tests: Capture the current expected behavior, including important edge cases.
Move authorization into policies: Remove duplicated permission checks from controllers and jobs.
Move request validation into form requests: Keep input concerns separate from workflow decisions.
Extract orchestration into an action: Give the business operation a single entry point.
Move entity rules closer to the model: Put true invariants and state transitions where they are easiest to reuse.
Wrap side effects intentionally: Use transactions, events, jobs, and integration boundaries based on the need for consistency.
Review the result as a pattern: Decide whether the same structure should be used for similar workflows.
This approach does not require stopping feature development for months. It lets you improve the architecture while continuing to ship.
Where should business logic live in a Laravel app? It depends on the type of logic. Authorization usually belongs in policies, request validation in form requests, workflow orchestration in actions or use cases, entity-level rules in models or value objects, and third-party calls behind integration services. The mistake is putting every kind of decision in controllers or generic service classes.
Are Laravel service classes bad? No. Service classes are useful when they have a clear, cohesive responsibility. The problem is vague service classes that collect unrelated behavior over time. In many cases, narrowly named action classes are easier to understand and test than broad services.
Should I use repositories to untangle business logic? Repositories can help in some applications, especially when data access is complex or must be abstracted. But they do not automatically solve tangled business logic. If the real problem is unclear workflows and scattered rules, adding repositories may simply create another layer to navigate.
How do I refactor business logic without breaking production? Start with one high-value workflow, add characterization tests, extract behavior in small steps, and keep changes vertical rather than sweeping across the whole codebase. Use transactions and focused tests to protect critical behavior during the refactor.
When is tangled business logic a sign that we need a rebuild? A rebuild may be worth discussing if the current architecture prevents basic changes, lacks testable seams, has deeply corrupted data assumptions, or cannot support the business model anymore. Many apps, however, can be rescued through disciplined refactoring rather than a full rewrite.
Business logic is where software risk gets real. It is also where experienced Laravel architecture matters most.
Ravenna works with teams that need their Laravel applications to be reliable, scalable, and easier to evolve. If your app has become difficult to change, or if important workflows are buried across controllers, models, jobs, and integrations, we can help you identify the right refactoring path and execute it carefully.
Start a conversation with Ravenna through our contact page.