In one sentence

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.

Why it matters

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.

The big picture

Who does what in a request

📨Form Requestvalidates input
🎛️Controllerdelegates, responds
🧠Servicebusiness rules
🗄️ModelsEloquent + DB
The controller speaks HTTP. The service speaks your business (“publish a post”). Models speak to the database.

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.
Step by step

Refactor a fat controller

We'll start from a real-looking “publish a post” controller and slim it down one move at a time.

  1. 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);
              }
            
  2. Create the service class

    There's no Artisan generator required. Make a folder and a class. (Laravel 11+ has php artisan make:class if you like generators.)

    
              php artisan make:class Services/PostService
            

    Move 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 Request or redirects. It takes plain data and returns a Post.

  3. 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'],
                  ];
              }
            
  4. 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”.

  5. 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',
                  ]);
              });
            
  6. 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 action

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

Common mistakes

Avoid this, do that

❌ Avoid

          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.

✅ Do

          public function publish(User $author, array $data): Post
          {
              // business rules only
          }
        

Accept plain data, return domain objects, and leave HTTP to the controller.

❌ Avoid

          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.

✅ Do

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

💡 Pro tip: watch out for the “God service”

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.

🏋️ Try it yourself
  1. Find the longest controller method in one of your projects and list every “job” it does.
  2. Create app/Services/CommentService.php with a post(User $user, Post $post, string $body): Comment method that saves the comment and notifies the post author.
  3. Inject it into CommentController@store so the method is 5 lines or fewer.
  4. Write one test that calls the service directly and asserts the notification was sent with Notification::assertSentTo().
Recap

Key takeaways

Check yourself

Mini quiz