We continue transforming our Controller method and moving logic elsewhere. Now we get to the lines which are probably the main logic of saving the data in the DB.
I see two main approaches where to offload that logic: Service and Action classes.
One of the goals of separating this from Controller is that the new method could be reused from multiple places: Web Controller, API Controller, Unit Tests, Jobs, etc.
Service Classes
First, let's demonstrate how to create a Service class.
The service class can be created manually or using an artisan command make:class.
php artisan make:class Services/UserService
The service class can look like this:
app/Services/UserService.php:
namespace App\Services; use App\Models\User; class UserService{ public function create(array $userData): User { $user = User::create($userData); $user->roles()->sync($userData['roles']); return $user; }}
Here, we make the create() method, which accepts an array of validated data. In this method, we create a user and sync roles and then return the created user.
Now, how do you call this Service in the Controller? There are at least two ways.
The first one is to initialize the service by doing the new UserService() and passing validated data to the create() method:
app/Http/Controllers/UserController.php:
use App\Services\UserService; // ... public function store(StoreUserRequest $request){ $user = (new UserService())->create($request->validated()); // ...}
The second is injecting the Service class into a method, type-hinting that, and assigning to a variable. So now our Controller would look like this:
use App\Services\UserService; // ... public function store(StoreUserRequest $request, UserService $userService){ $user = $userService->create($request->validated()); // ...}
Laravel has this "magic" of auto-resolving the class in Controller methods if you type-hint it. If you want to find out more about it, I have this article: Laravel Service Container: What Beginners Need to Know
Service into Action: What's the Difference?
Another alternative to a Service class is called an Action class. Again, PHP class can be created manually or using Artisan command make:class.
php artisan make:class Actions/CreateUserAction
Inside, typically there's one method called handle() or execute().
app/Actions/CreateUserAction.php:
namespace App\Actions; use App\Models\User; class CreateUserAction{ public function execute(array $userData): User { $user = User::create($userData); $user->roles()->sync($userData['roles']); return $user; }}
To call this Action class in the Controller, you would just initialize the action and call the execute() method by passing data to it.
app/Http/Controllers/UserController.php:
use App\Actions\CreateUserAction; // ... public function store(StoreUserRequest $request){ $user = (new CreateUserAction())->execute($request->validated()); // ...}
Or, similarly to the Service class, you can type-hint the Action class:
use App\Actions\CreateUserAction; // ... public function store(StoreUserRequest $request, CreateUserAction $action){ $user = $action->execute($request->validated()); // ...}
As you can see, there's not much syntax difference when using Service and Action classes.
The difference is more about how YOU want to divide your logic:
- Either into model-related entities like
UserServiceorTaskService, with many methods inside - Or, each operation as an Action class like
CreateUserActionorUpdateUserAction, with one method inside
Of course, inside that Action class, you may also have some private methods for more logic if you have something more complicated. But at its core, Action is similar to a one-time Job class, just without a queue mechanism. By the way, Jobs will be the exact topic of the next lesson.
Single Action Controllers (Invokable Controllers)
Notice the ApproveStreamController from the larastreamers example above? It only has one method: __invoke(). This is called a Single Action Controller or Invokable Controller.
If you're already creating single-purpose Action classes, why not single-purpose Controllers too? Many teams use invokable controllers as their default structure.
php artisan make:controller StoreUserController --invokable
app/Http/Controllers/StoreUserController.php:
class StoreUserController extends Controller{ public function __invoke(StoreUserRequest $request, CreateUserAction $action) { $user = $action->execute($request->validated()); return response()->json(['data' => $user], 201); }}
In your routes, you reference the controller class directly — no method name needed:
Route::post('/users', StoreUserController::class);
The tradeoff: you get focused, easy-to-find controllers (one file = one action), but you also get more files. For a simple CRUD resource, a traditional UserController with index, store, show, update, destroy methods is often simpler. But for complex operations that don't fit standard CRUD — like ApproveStreamController — invokable controllers are a great fit.
Contextual Container Attributes for Dependency Injection
When injecting Services into Controllers, Laravel resolves them from the Service Container. But what if your Service needs a specific configuration — like which cache store to use, or which database connection?
Laravel provides Contextual Container Attributes that let you declaratively specify dependencies using PHP Attributes:
use Illuminate\Container\Attributes\Config;use Illuminate\Container\Attributes\Cache;use Illuminate\Container\Attributes\Log; class UserService{ public function __construct( #[Config('app.timezone')] protected string $timezone, #[Cache('redis')] protected \Illuminate\Contracts\Cache\Repository $cache, #[Log('daily')] protected \Psr\Log\LoggerInterface $logger, ) {} public function create(array $userData): User { $this->logger->info('Creating user', $userData); $user = User::create($userData); $user->roles()->sync($userData['roles']); $this->cache->forget('users.count'); return $user; }}
Available attributes include #[Auth], #[Cache], #[Config], #[DB], #[Log], and #[Tag]. This replaces manual bindings in AppServiceProvider with inline, readable declarations.
This is particularly useful when you have multiple implementations of the same interface and want to specify which one to inject.
Open-Source Examples
Example Project 1. ash-jc-allen/find-a-pr
The service example is from a ash-jc-allen/find-a-pr open-source project. The Service has a method to return a Collection of GitHub repositories.
final readonly class RepoService{ public function reposToCrawl(): Collection { return collect(config('repos.repos')) ->merge($this->fetchReposFromOrgs()) ->flatMap(function (array $repoNames, string $owner): array { return Arr::map( $repoNames, static fn (string $repoName): Repository => new Repository($owner, $repoName) ); }); } // ...}
This is a perfect example of a Service because it is reused in multiple places: an Artisan command and a Livewire component.
The first usage example:
app/Console/Commands/PreloadRepoData.php:
final class PreloadRepoData extends Command{ protected $signature = 'repos:preload'; protected $description = 'Preload the repos and cache them to improve load time.'; public function handle(): int { $this->components->info('Preloading and caching issues...'); $batches = app(RepoService::class) ->reposToCrawl() ->chunk(25) ->map(function (Collection $repos): PreloadIssuesForRepos { return new PreloadIssuesForRepos($repos); }) ->all(); // ... }}
The second usage example:
final class ListIssues extends Component{ // ... public function mount(): void { $this->setSortOrderOnPageLoad(); $this->labels = config('repos.labels'); $this->repos = app(RepoService::class)->reposToCrawl()->sort(); try { $this->originalIssues = app(IssueService::class)->getAll()->shuffle(); } catch (GitHubRateLimitException $e) { abort(503, $e->getMessage()); } $this->shouldDisplayFirstTimeNotice = ! Cookie::get('firstTimeNoticeClosed'); } // ...}
Example Project 2. christophrumpel/larastreamers
Next, let's look at how the Action class can be used.
For this example, we will look at christophrumpel/larastreamers, an open-source project.
In this project, there is an action called ApproveStreamAction. That action is responsible for everything related to approving the stream:
- getting info from YouTube
- setting that the stream is approved
- sending emails
- etc.
app/Actions/Submission/ApproveStreamAction.php:
class ApproveStreamAction{ public function handle(Stream $stream): void { if ($stream->approved_at) { return; } $streamData = YouTube::video($stream->youtube_id); (new UpdateStreamAction())->handle($stream, $streamData); if (is_null($stream->channel_id)) { Artisan::call(ImportChannelsForStreamsCommand::class, ['stream' => $stream]); } $stream->update(['approved_at' => now()]); Mail::to($stream->submitted_by_email)->queue(new StreamApprovedMail($stream)); }}
Also, notice that there's another Action class UpdateStreamAction used in this Action class.
Then, the Controller only has two lines of code:
- one for calling the Action
- and the second for returning a View
app/Http/Controllers/Submission/ApproveStreamController.php:
class ApproveStreamController{ public function __invoke(Stream $stream, ApproveStreamAction $approveStream): View { $approveStream->handle($stream); return view('pages.streamApproved'); }}
So yeah, if you want to separate the action of saving the data to DB, you may (again!) move it from Controller to a Service or an Action class.
You forgot change code in block after:
Thank you!
Thank you for bringing this to us. Updated the example!
Hi, I don't believe the update was done...initializing and type-hinting examples looks similar to me.
Whoops! Sorry about that, there was indeed a caching issue on our end!