Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Creating Your Own Services

Creating Your Own Services

~15 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026

Time to extract our FIRST piece of custom business logic into a dedicated service, instead of leaving it in the controller – the statistics computation from chapter 24.

Why business logic belongs outside the controller

Controllers SHOULD stay thin: receive the request, delegate to the right logic, return the response. More complex computations that combine SEVERAL repositories or should be reusable (e.g. also from a console command in block 7) belong in a DEDICATED service.

Generating a service

php bin/console make:service ProjectStatsService
src/Service/ProjectStatsService.php
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Project;
use App\Repository\TaskRepository;

class ProjectStatsService
{
    public function __construct(
        private readonly TaskRepository $taskRepository,
    ) {
    }

    /**
     * @return array{open: int, in_progress: int, done: int, progress: float}
     */
    public function computeStats(Project $project): array
    {
        $rawStats = $this->taskRepository->countTasksByStatus($project);

        $countByStatus = ['open' => 0, 'in_progress' => 0, 'done' => 0];
        foreach ($rawStats as $row) {
            $countByStatus[$row['status']] = $row['count'];
        }

        $total = array_sum($countByStatus);
        $progress = $total > 0
            ? round(($countByStatus['done'] / $total) * 100, 1)
            : 0.0;

        return [
            ...$countByStatus,
            'progress' => $progress,
        ];
    }
}

NO attributes, NO special base class needed – ANY ordinary PHP class with typed constructor parameters is AUTOMATICALLY usable as a service via autowiring (Symfony's "services are autowired and autoconfigured by default" convention from config/services.yaml, more on that in chapter 34).

Using the service in the controller

use App\Service\ProjectStatsService;

#[Route('/projects/{id}', name: 'project_show', requirements: ['id' => '\d+'])]
public function show(
    int $id,
    ProjectRepository $projectRepository,
    ProjectStatsService $statsService,
): Response {
    $project = $projectRepository->find($id);

    if ($project === null) {
        throw $this->createNotFoundException();
    }

    $this->denyAccessUnlessGranted(ProjectVoter::VIEW, $project);

    return $this->render('project/show.html.twig', [
        'project' => $project,
        'stats' => $statsService->computeStats($project),
    ]);
}

EXACTLY the same autowiring principle as with LoggerInterface or ProjectRepository – the controller doesn't know HOW ProjectStatsService works internally, only THAT it can call computeStats().

Services can use other services

Autowiring works IDENTICALLY between services – a service can request any number of OTHER services in its own constructor, EXACTLY like a controller. ProjectStatsService itself could, for instance, later get a CacheInterface service injected (chapter 45), WITHOUT the calling controller code needing to change.

Rule of thumb: when does a custom service pay off?

SituationRecommendation
Simple, ONE-OFF logic in ONE controller methodLeave it directly in the controller – a custom service would be unnecessary indirection.
Logic needed from MULTIPLE places (controller AND console command AND event listener)Extract into a service – ONE source of truth, instead of code duplication.
Complex computation/orchestration of MULTIPLE repositoriesExtract into a service – keeps the controller readable and the logic testable in isolation (block 7).

Tipp: A good naming convention tip: service class names often end in Service, Manager, or directly describe their task (e.g. ProjectStatsCalculator) – MORE IMPORTANT than the exact naming style is CONSISTENCY within a project.