A service class is a plain PHP class that holds your business logic, so controllers only receive the request, hand the work off and return a response.
🍽️A restaurant waiter takes your order and brings the plate, but doesn't cook. The kitchen (the service) does the real work. If the waiter also cooked, washed dishes and did the accounts, every table would wait, and nobody else could reuse the recipes.
Fat controllers get painful fast
Reuse the same logic
Publishing a post from a web form, an API endpoint and an Artisan command should run the same code, not three copies of it.
Easier to test
You can test a service by calling one method. You don't need to fake an HTTP request just to check a calculation.
Controllers you can read
A five-line controller method tells you the whole story at a glance: validate, delegate, respond.
Who does what in a request
A service class isn't a special Laravel feature. It's just a class you write, usually in app/Services. Laravel's service container can build it for you and inject it anywhere, with no registration needed.
- Business logic
- The rules that make your app your app: “a post gets a slug”, “authors are notified”, “prices include tax”.
- Fat controller
- A controller method that validates, calculates, saves, emails and formats, all in one place.
- Thin controller
- A controller that only translates HTTP in and out, and delegates the real work.
Refactor a fat controller
We'll start from a real-looking “publish a post” controller and slim it down one move at a time.
-
Spot the fat controller
This method works, but it does five jobs: validation, slug creation, saving, tagging and notifying. None of it can be reused from an API or a command.
public function store(Request $request) { $data = $request->validate([ 'title' => ['required', 'max:255'], 'body' => ['required'], 'tags' => ['array'], ]); $slug = Str::slug($data['title']); if (Post::where('slug', $slug)->exists()) { $slug .= '-' . Str::random(5); } $post = $request->user()->posts()->create([ ...$data, 'slug' => $slug, 'published_at' => now(), ]); $post->tags()->sync($data['tags'] ?? []); foreach ($request->user()->followers as $follower) { $follower->notify(new PostPublished($post)); } return redirect()->route('posts.show', $post); } -
Create the service class
There's no Artisan generator required. Make a folder and a class. (Laravel 11+ has
php artisan make:classif you like generators.)php artisan make:class Services/PostServiceMove the business steps into a method named after what the business calls it:
publish.<?php namespace App\Services; use App\Models\Post; use App\Models\User; use App\Notifications\PostPublished; use Illuminate\Support\Str; class PostService { public function publish(User $author, array $data): Post { $post = $author->posts()->create([ ...$data, 'slug' => $this->uniqueSlug($data['title']), 'published_at' => now(), ]); $post->tags()->sync($data['tags'] ?? []); $author->followers->each->notify(new PostPublished($post)); return $post; } private function uniqueSlug(string $title): string { $slug = Str::slug($title); return Post::where('slug', $slug)->exists() ? $slug . '-' . Str::random(5) : $slug; } }Notice the service knows nothing about
Requestor redirects. It takes plain data and returns aPost. -
Move validation into a Form Request
Validation is HTTP's job, so it belongs in a Form Request, not in the service.
public function rules(): array { return [ 'title' => ['required', 'max:255'], 'body' => ['required'], 'tags' => ['array'], ]; } -
Inject the service and slim the controller
Type-hint the service in the method (or the constructor). The container builds it automatically.
public function store(StorePostRequest $request, PostService $posts) { $post = $posts->publish($request->user(), $request->validated()); return redirect()->route('posts.show', $post) ->with('status', 'Post published!'); }From 22 lines to 4. The controller now reads like a sentence: “validate, publish, redirect”.
-
Reuse it everywhere
The same service now powers an API controller and an Artisan command with no duplication.
public function store(StorePostRequest $request, PostService $posts) { $post = $posts->publish($request->user(), $request->validated()); return new PostResource($post); }Artisan::command('posts:publish-draft {user} {title}', function (PostService $posts) { $posts->publish(User::findOrFail($this->argument('user')), [ 'title' => $this->argument('title'), 'body' => 'Draft', ]); }); -
Test the service directly
A feature test can call the service without touching HTTP. Here it is with PHPUnit (Pest works the same way with
it()).public function test_publishing_creates_a_unique_slug(): void { Notification::fake(); $author = User::factory()->create(); Post::factory()->create(['slug' => 'hello-world']); $post = app(PostService::class)->publish($author, [ 'title' => 'Hello World', 'body' => 'Hi', ]); $this->assertNotSame('hello-world', $post->slug); $this->assertStringStartsWith('hello-world-', $post->slug); }ℹ️ Service vs actionA service usually groups several related methods (
publish,unpublish,schedule). If you prefer one class per operation, see Actions & DTOs. Both are fine; pick one style per project.
Avoid this, do that
public function publish(Request $request)
{
$data = $request->validate([...]);
// ...
return redirect('/posts');
}
A service that reads Request and returns redirects is just a controller in disguise; it can't be reused from a job or command.
public function publish(User $author, array $data): Post
{
// business rules only
}
Accept plain data, return domain objects, and leave HTTP to the controller.
class PostService
{
public function all() { return Post::all(); }
public function find($id) { return Post::find($id); }
}
Wrapping one-line Eloquent calls adds a layer with no benefit. That's not business logic.
// Simple reads stay in the controller
$posts = Post::latest()->paginate(10);
Extract a service when there are real rules or side effects, not for every query.
If PostService grows past a handful of methods or a few hundred lines, split it by job (PostPublisher, PostScheduler) or switch to action classes. A bloated service is just a fat controller that moved house.
- Find the longest controller method in one of your projects and list every “job” it does.
- Create
app/Services/CommentService.phpwith apost(User $user, Post $post, string $body): Commentmethod that saves the comment and notifies the post author. - Inject it into
CommentController@storeso the method is 5 lines or fewer. - Write one test that calls the service directly and asserts the notification was sent with
Notification::assertSentTo().
Key takeaways
- A service class is a plain PHP class for business logic, usually in
app/Services. - Controllers should validate (via Form Requests), delegate to a service, and return a response.
- Services take plain data and return models or values. They never touch
Requestor redirects. - The service container injects services automatically. Just type-hint them.
- Extract a service when logic is reused or has side effects, not for one-line queries.
- Split services that grow too big, or switch to single-purpose action classes.