Originally published at hafiz.dev
A few years ago I had a UserService that had grown to 28 methods. It handled registration, email verification, password resets, subscription upgrades, profile updates, and account deletion. It was the most-imported class in the codebase and the most dangerous to touch. One change to the registration flow meant scrolling past 400 lines of code that had nothing to do with it.
At some point I stopped and asked myself what a Service class actually is in Laravel. There's no php artisan make:service command. No interface requirement. No convention in the framework documentation telling you what belongs there. We just started putting things in Services because someone told us to move logic out of controllers, and Services were the first pattern we learned.
The result is predictable. The controller gets thin. The Service gets fat. The problem moved, it didn't get solved.
This post is the decision tree I wish I'd had at the start.
The three patterns, briefly
Before the tree, a quick grounding on what each pattern actually does, because the language is inconsistent in the community.
A Service class groups related operations on the same domain object. A SubscriptionService knows how to subscribe a user, upgrade them, cancel them, and check their current plan. It holds methods, not just one. Think of it as the "everything about subscriptions" class. It can be stateful or stateless.
An Action class handles a single, atomic operation. CreateUser, SendPasswordResetEmail, ChargePaymentMethod. One class, one job, one public method. It can't be a Job because you need the result immediately. It can be reused from a controller, an Artisan command, another class, and a test. The key word is reuse across contexts.
A Job runs asynchronously on the queue. Full stop. That's the definition. If your code doesn't need to run in the background, it's not a Job, no matter how tempting it is to make it one. Jobs are for fire-and-forget work where you don't need the result before the HTTP response returns.
There's some overlap. But the overlap is a signal that you need to choose, not that both options are equivalent.
The decision tree
Walk through it with any piece of logic and you'll get an answer.
What goes wrong when you ignore the tree
The bloated Service. You create a UserService for user registration. Six months later someone adds password reset to it because, well, it's user-related. Then profile updates. Then account deletion. Then a method that checks if the user is eligible for a discount. Your Service is now a 600-line class with no coherent identity. Every test for registration pulls in the entire class including all the subscription logic you never wanted to touch.
The fix: split by responsibility, not by model. RegistrationService handles registration. PasswordResetService handles password resets. Or, if those operations are simple and atomic, make them Actions instead.
The Action folder explosion. The opposite problem. You adopt the Action pattern and start creating an Action for every single thing. GetUserByEmailAction. FormatDateAction. ValidatePostcodeAction. After 200 files, navigating app/Actions is slower than just looking in the controller.
Actions should answer a clear question: "Would I want to call this from a controller and from an Artisan command and from a queue job?" If the answer is no, keep it inline.
Jobs used as glorified Actions. The most common mistake. You've got some logic that feels heavy, so you make it a Job. But you dispatch it synchronously with dispatch()->now(), or you realise you need the return value, so you jump through hoops. A Job that you always dispatch synchronously and need a result from is just an Action wearing the wrong costume. Make it an Action.
Real examples from production
A few scenarios I've hit that show how the tree plays out.
Charging a subscription. Is it async? No. You need the result to know whether to give the user access. Single operation? Yes. Used in multiple places (web checkout, API checkout, CLI seeder)? Yes. This is an Action: ChargeSubscription::handle($user, $plan).
Sending a weekly digest email to 50,000 users. Async? Yes. Job. You chunk the users, dispatch one Job per batch, move on. The Job calls a Mailable. You don't need the result in the HTTP response.
Everything related to how a subscription works. Subscribe, cancel, check status, apply promo code, handle webhook from Stripe. These are multiple operations on the same domain concept, with shared logic (checking existing state before changing it). This is a Service: SubscriptionService. Each method handles one transition, but they all need to know about each other.
Sending a single transactional email. Async? Yes, probably. But Mail::queue() handles that. You don't need a Job class wrapping a Mailable. The Mailable itself handles queueing when sent via Mail::queue(). This is one of those cases where the extra class adds ceremony without adding value.
The part people argue about
The honest version of this debate is that Service vs Action is often a team preference more than a technical requirement. Both work. The real risk isn't choosing the wrong one. It's applying one pattern to everything regardless of fit.
I use Services when I'm working on a domain area with multiple operations that share state or context. I use Actions when I have a single operation I know I'll call from multiple places. I use neither when the logic is simple enough to live in the controller and I don't expect to reuse it.
The rule I enforce on my own projects: if a class has more than five methods or more than 150 lines, it needs to be split or reconsidered. That ceiling forces the question "what does this class actually do?" before it becomes a dumping ground.
For a deeper look at async patterns and how Jobs fit into a larger queue architecture, the queue jobs guide covers sizing workers, retries, and the production setup worth knowing before you start dispatching at scale.
FAQ
Should I always use an Action over a Service for new code?
Not always. Actions work best for atomic, stateless operations with a single entry point. If you're building a domain area (subscriptions, invoicing, notifications) where multiple operations share context, a Service is cleaner. The question to ask is: does this class do one thing, or does it know everything about a concept?
Where should Actions live in the directory structure?
app/Actions/. For larger projects, namespace further by domain: app/Actions/Billing/ChargeSubscription.php. Keep the name as a verb-noun pair: CreateUser, SendPasswordReset. Avoid names like UserAction. That's just a Service with a different suffix.
What about the lorisleiva/laravel-actions package?
It's a solid package. It lets a single Action class run as a controller, a Job, a listener, and a command depending on context. Worth considering if your team commits to the pattern across the codebase. For testing Actions and Services, the Pest 4 testing guide covers the isolation patterns. It adds real value when you have Actions that need to run in multiple contexts. For simpler projects it's additional overhead.
Can a Job call an Action?
Yes, and this is often the right pattern. The Job handles the queue mechanics (retries, delays, backoff). The Action handles the actual logic. ProcessSubscriptionRenewal::handle() dispatches work, calls ChargeSubscription::handle(), and handles the queue-specific failure cases. Clean separation.
What about Events and Listeners?
Events and Listeners are for decoupled side effects after something happens. The Events, Listeners, and Observers guide covers the patterns in depth. A user registered, fire UserRegistered, let the listeners handle the welcome email, the onboarding sequence, the analytics event. Don't use Actions or Services for this. That's the Events system doing its job. The relationship is: Actions and Services cause things to happen, Events communicate that they happened.
The actual takeaway
The pattern you choose matters less than applying it consistently and knowing when to break from it. Service classes aren't wrong. Actions aren't always better. Jobs aren't for synchronous logic dressed up with a queue.
The moment a class starts doing too many things, it's a signal to reach for the tree.
Got a codebase you're trying to untangle? Get in touch.
Top comments (0)