How to Untangle Business Logic in a Laravel App

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.

What business logic means in a Laravel app

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.

Signs your business logic is tangled

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.

Start by mapping the workflows, not the files

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?”

Separate Laravel concerns from business decisions

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 if ($user->role...) checks everywhere

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.

Use actions or use cases for workflows

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.

Keep Eloquent models meaningful, not bloated

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.

Make illegal states harder to represent

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.

Put integrations behind boundaries

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.

Use events carefully, not as a hiding place

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.

Add tests before and during the untangling

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.

Refactor in vertical slices

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.

Avoid overcorrecting into unnecessary complexity

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.

Create team conventions so the mess does not return

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.

A practical sequence for untangling business logic

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:

  1. Name the workflow clearly: Choose one operation, such as approving an invoice, onboarding a customer, or publishing a course.

  2. Write characterization tests: Capture the current expected behavior, including important edge cases.

  3. Move authorization into policies: Remove duplicated permission checks from controllers and jobs.

  4. Move request validation into form requests: Keep input concerns separate from workflow decisions.

  5. Extract orchestration into an action: Give the business operation a single entry point.

  6. Move entity rules closer to the model: Put true invariants and state transitions where they are easiest to reuse.

  7. Wrap side effects intentionally: Use transactions, events, jobs, and integration boundaries based on the need for consistency.

  8. 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.

Frequently Asked Questions

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.

Need a senior Laravel team to help untangle the hard parts?

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.