Courses

How to Structure Laravel 11 Projects

Save Data: Service or Action?

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 = (new CreateUserAction())->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 UserService or TaskService, with many methods inside
  • Or, each operation as an Action class like CreateUserAction or UpdateUserAction, 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.


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.

app/Services/RepoService.php:

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:

app/Livewire/ListIssues.php:

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.

avatar

You forgot change code in block after:

Or, similarly to the Service class, you can type-hint the Action class:

Thank you!

avatar

Thank you for bringing this to us. Updated the example!