LaravelPatternsRefactoring

Three Laravel patterns that stop controller sprawl

Service objects, query objects, and presenters keep your Laravel code readable. Here's how we teach them.

WebPrims Team19 October 20265 min read
Three Laravel patterns that stop controller sprawl

Every June, a student shows us a controller that's 400 lines long. The action validates input, updates a user's subscription, and sends a Slack notification. We tell them to stop. Then we show them three patterns that fix the mess: service objects, query objects, and view presenters. These aren't theoretical. They're the difference between a codebase you can debug at 2 a.m. and one that makes you quit.

Service objects for the heavy lifting

A controller should know what to do, not how to do it. When an action has more than two steps, that's a signal the logic belongs elsewhere. Service objects are plain Ruby classes that wrap a single business operation. They take input, return a result, and keep the controller thin.

Here's a typical messy controller from a student project:

def upgrade
  @user = User.find(params[:id])
  Plan.transaction do
    @user.subscription.update!(plan: params[:plan])
    @user.credit_card.update!(token: params[:token])
    Notifier.slack("user_upgraded", @user.id)
  end
  redirect_to @user, notice: "Upgraded."
end

That's three responsibilities in one action. If the Slack call fails, the whole transaction rolls back. If you need to test the upgrade logic, you have to simulate a full HTTP request. A service object fixes both problems:

class UpgradeUser
  Result = Struct.new(:success, :user, :error)

  def self.call(user, plan, token)
    new(user, plan, token).call
  end

  def initialize(user, plan, token)
    @user = user
    @plan = plan
    @token = token
  end

  def call
    Plan.transaction do
      @user.subscription.update!(plan: @plan)
      @user.credit_card.update!(token: @token)
      Notifier.slack("user_upgraded", @user.id)
    end
    Result.new(true, @user, nil)
  rescue ActiveRecord::RecordInvalid => e
    Result.new(false, @user, e.record.errors)
  end
end

Now the controller is two lines: call the service, then redirect based on the result. You can test UpgradeUser in isolation, without touching routes or sessions. We tell students to put service objects in app/services/ – plain Ruby, no inheritance, just a class with a .call class method. That's it. Don't overcomplicate it with dependency injection or abstract base classes. The point is to move logic out of the controller, not to build a framework.

One mistake we see every week: people put service objects in the model. That's worse than leaving the logic in the controller. Models already have enough jobs. Keep service objects separate. If you're not sure whether to use one, count the ifs in your action. More than two conditionals? That's a service.

Query objects keep the model readable

The second pattern we teach is the query object. Models accumulate scopes like barnacles. At some point, User.active.subscribed.in_region stops being readable. A query object extracts complex queries into their own class.

We had a student who needed to find "premium users who haven't logged in for 30 days but have an active trial". The scope chain was six lines long and impossible to test. We suggested a query object:

class ExpiringPremiumUsers
  def self.call
    User.joins(:subscription)
        .where(subscriptions: { plan: "premium" })
        .where("last_sign_in_at < ?", 30.days.ago)
        .where("trial_ends_at > ?", Time.now)
  end
end

Then the controller calls ExpiringPremiumUsers.call instead of a chain of scopes. The query object is a named, testable unit. You can write a spec for it that doesn't care about the controller or the model. We keep these in app/queries/.

The rule of thumb we give: if a scope chain is longer than three or needs a where with raw SQL, extract it. Don't do this for every simple User.where(active: true) – that's over-engineering. But when you see the same five-line query in three places, it's time.

Query objects also make it easier to change the database later. If you migrate from Postgres to MySQL, you only fix the query object, not every controller that called the scope. That's a benefit you'll appreciate in month eight of a project.

View presenters for presentation logic

The third pattern is the view presenter (or decorator). Views should not know business rules. We see students writing if @user.admin? && @user.subscription.trial? inside a partial. That's logic that belongs in a presenter.

A presenter is a small class that wraps a model and exposes view-ready methods. For example:

class UserPresenter
  def initialize(user)
    @user = user
  end

  def account_status
    if @user.admin?
      "Admin"
    elsif @user.subscription.trial?
      "Trial (ends #{@user.subscription.trial_ends_at.strftime("%b %d")})"
    else
      @user.subscription.plan.humanize
    end
  end
end

Now the view calls presenter.account_status and nothing else. We use a simple delegate or just call it directly. No need for a gem like Draper unless you want it. The key is that the view stops making decisions.

We had a student who put a discount_percentage calculation in the view for a Black Friday sale. When the sale ended, they had to find and remove that code from three different templates. A presenter would have made it one method, changed in one place. We don't use presenters for every attribute – only when there's conditional logic or formatting. If it's just @user.name, don't wrap it.

The three patterns work together. A service object does the write, a query object does the read, and a presenter does the display. They each have a single job, and they keep each other honest. When we see a controller with a service call, a query call, and a presenter assignment, we know the developer understood the assignment.

Common questions

Should I use these patterns from the start of a project?

No. Start with a simple controller and model. Add a service object when an action grows past three steps or you need to test business logic in isolation. Add a query object when you repeat the same scope chain twice. Add a presenter when a view starts checking conditions. Patterns are solutions to problems you've already hit, not decorations for greenfield code.

How do I decide between a service object and a model method?

If the logic involves multiple models or a transaction, use a service object. If it's a single model's own behavior, a model method is fine. A service object that only touches one model is often overkill. Ask yourself: would this be awkward to call as User.find(...).upgrade_to_premium? If yes, it's a service.

Do these patterns slow down the app?

Not in any measurable way. A service object is just a class instantiation. A query object is a class method that returns a relation. A presenter is a wrapper. The overhead is negligible compared to a single database query. The real cost is in your own discipline – you have to stop yourself from taking the shortcut of throwing logic into the controller. That's a habit, not a performance issue.

Come and sit a class before you decide

Reading about it only gets you so far. Pick a day, sit in on a class that is already running, write some code, and talk to the students in it. Free, and nothing to pay afterwards unless you want to join.

LaravelPatternsRefactoring

Found this useful?

Share it with someone who is learning.

CallWhatsAppFree demo