Controllers

A controller is any PHP callable that returns a Response; AbstractController only adds shortcuts on top of that rule.

Syntaxpublic function action(): Symfony\Component\HttpFoundation\Response

A controller is the code that turns one request into one response. In Symfony it is any PHP callable that returns a Response object. That is the whole contract. A method on a class, a closure, or an object with __invoke() all qualify.

A Response holds three things: a body, a status code, and headers. Symfony sends back whatever object your controller returned. Nothing is inferred. Return a string or an array and Symfony throws a LogicException saying a Response was expected.

Why most controllers extend AbstractController

AbstractController lives in FrameworkBundle. It does not change the contract. It adds short protected methods for work you do constantly: json(), render(), redirectToRoute(), generateUrl(), addFlash(), createNotFoundException() and file(). Extending it is a convenience, never a requirement.

Shorthand and longhand

ShorthandThe same thing, written out
$this->json($data)new JsonResponse($data)
$this->redirectToRoute('app_home')new RedirectResponse($this->generateUrl('app_home'))
$this->file('/tmp/a.pdf')new BinaryFileResponse('/tmp/a.pdf')

What trips people up

The route attribute is #[Route] from Symfony\Component\Routing\Attribute\Route. Docblock annotations such as @Route in a comment were removed in Symfony 7.0 and do nothing in 8.1. Classes under src/Controller/ are picked up automatically, so a controller placed elsewhere may never be found.

Example

Example · php
// src/Controller/ArticleController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleController extends AbstractController
{
    #[Route('/articles/count', name: 'app_article_count', methods: ['GET'])]
    public function count(): Response
    {
        // Body, status code, headers. Nothing more to a Response than that.
        return new Response(
            '42 articles',
            Response::HTTP_OK,
            ['Content-Type' => 'text/plain; charset=utf-8'],
        );
    }

    #[Route('/articles/{id<\d+>}', name: 'app_article_show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        $article = ['id' => $id, 'title' => 'Hinges and hooks'];

        // Shorthand: json() encodes the data and builds a JsonResponse.
        return $this->json($article);

        // Longhand, same bytes on the wire:
        // return new JsonResponse($article);
    }
}

When to use it

  • Serve an HTML page or a JSON document from a URL the reader has just routed.
  • Add a single-purpose endpoint, such as a health check, as one invokable class.
  • Keep old URLs working by redirecting them to a current route name.
  • Answer a write request with 201 or 204 instead of an empty 200.

More examples

A controller with no base class

Defining __invoke() makes the object itself callable, so the class name alone identifies the controller. GET /health returns 200 with the body "ok". Reach for this shape when an endpoint does exactly one thing; it also proves that the Response return type, not the base class, is what makes something a controller.

Example · php
// src/Controller/HealthController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class HealthController
{
    #[Route('/health', name: 'app_health', methods: ['GET'])]
    public function __invoke(): Response
    {
        // No AbstractController, no helpers, still a valid controller.
        return new Response('ok', Response::HTTP_OK, [
            'Content-Type' => 'text/plain; charset=utf-8',
            'Cache-Control' => 'no-store',
        ]);
    }
}

Redirecting, both ways round

Both actions answer with 301 and a Location header pointing at /articles/count. Seeing the pair side by side matters because other people's code uses either form, and redirectToRoute() takes a route name while RedirectResponse takes a finished URL. Mixing them up produces a redirect to the literal string "app_article_count".

Example · php
// src/Controller/LegacyController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class LegacyController extends AbstractController
{
    #[Route('/old-articles', name: 'app_article_legacy', methods: ['GET'])]
    public function legacy(): Response
    {
        // 301 tells caches and search engines the move is permanent.
        return $this->redirectToRoute(
            'app_article_count',
            [],
            Response::HTTP_MOVED_PERMANENTLY,
        );
    }

    #[Route('/old-articles-written-out', name: 'app_article_legacy_long', methods: ['GET'])]
    public function legacyLonghand(): Response
    {
        // Identical result without the helper: build the URL, then the response.
        $url = $this->generateUrl('app_article_count');

        return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY);
    }
}

A body-less response

DELETE /subscribers/[email protected] returns 204 with an empty body. The Response constructor accepts null content, which is the honest way to say nothing is being sent. Returning an empty JSON object with 200 instead forces every client to guess whether the work happened.

Example · php
// src/Controller/SubscriberController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SubscriberController extends AbstractController
{
    #[Route('/subscribers/{email}', name: 'app_subscriber_delete', methods: ['DELETE'])]
    public function delete(string $email): Response
    {
        // ... remove the subscriber

        // 204 means "done, and there is deliberately no body".
        return new Response(null, Response::HTTP_NO_CONTENT);
    }
}

Discussion

  • Be the first to comment on this lesson.