Add custom 404 and 500 error pages via ExceptionListener

- Create ExceptionListener that renders custom error templates in prod
- Skip in dev environment to keep Symfony debug pages
- 404: neo-brutalist page with large "404" text and return link
- 500: generic server error page with status code display
- Add 4 unit tests (dev skip, 404 render, 500 render, other HTTP codes)
- Register kernel.environment parameter for ExceptionListener

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-20 14:37:25 +01:00
parent bc93a1c9d5
commit 0fb51726bc
5 changed files with 185 additions and 0 deletions

View File

@@ -34,6 +34,10 @@ services:
key: '%env(S3_ACCESS_KEY)%'
secret: '%env(S3_SECRET_KEY)%'
App\EventListener\ExceptionListener:
arguments:
$kernelEnvironment: '%kernel.environment%'
App\Twig\ViteAssetExtension:
arguments:
$manifest: '%kernel.project_dir%/public/build/.vite/manifest.json'

View File

@@ -0,0 +1,47 @@
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
#[AsEventListener(event: KernelEvents::EXCEPTION)]
class ExceptionListener
{
public function __construct(
private readonly Environment $twig,
private readonly string $kernelEnvironment,
) {
}
public function __invoke(ExceptionEvent $event): void
{
if ('dev' === $this->kernelEnvironment) {
return;
}
$exception = $event->getThrowable();
$statusCode = $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500;
if (404 === $statusCode) {
$template = 'error/404.html.twig';
} else {
$template = 'error/500.html.twig';
}
$response = new Response(
$this->twig->render($template, ['status_code' => $statusCode]),
$statusCode,
);
if ($exception instanceof HttpExceptionInterface) {
$response->headers->add($exception->getHeaders());
}
$event->setResponse($response);
}
}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block title %}Page introuvable - E-Ticket{% endblock %}
{% block body %}
<div class="bg-[#fbfbfb] overflow-x-hidden italic font-sans">
<section class="relative flex items-center justify-center px-4 pt-20 pb-16 min-h-[60vh]">
<div class="absolute inset-0 opacity-[0.03] pointer-events-none select-none overflow-hidden">
<span class="text-[20rem] font-black uppercase leading-none block -rotate-12 translate-y-10">404</span>
</div>
<div class="max-w-2xl mx-auto relative z-10 text-center">
<div class="border-4 border-gray-900 bg-white shadow-[6px_6px_0px_rgba(0,0,0,1)] p-12">
<div class="text-8xl font-black uppercase tracking-tighter mb-4">404</div>
<h1 class="text-3xl font-black uppercase tracking-tighter italic mb-4">Page introuvable</h1>
<p class="font-bold text-gray-500 italic mb-8">La page que vous cherchez n'existe pas ou a ete deplacee.</p>
<a href="/" style="padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;display:inline-block;" class="font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Retour a l'accueil
</a>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block title %}Erreur serveur - E-Ticket{% endblock %}
{% block body %}
<div class="bg-[#fbfbfb] overflow-x-hidden italic font-sans">
<section class="relative flex items-center justify-center px-4 pt-20 pb-16 min-h-[60vh]">
<div class="absolute inset-0 opacity-[0.03] pointer-events-none select-none overflow-hidden">
<span class="text-[20rem] font-black uppercase leading-none block -rotate-12 translate-y-10">500</span>
</div>
<div class="max-w-2xl mx-auto relative z-10 text-center">
<div class="border-4 border-gray-900 bg-white shadow-[6px_6px_0px_rgba(0,0,0,1)] p-12">
<div class="text-8xl font-black uppercase tracking-tighter mb-4">{{ status_code }}</div>
<h1 class="text-3xl font-black uppercase tracking-tighter italic mb-4">Erreur serveur</h1>
<p class="font-bold text-gray-500 italic mb-8">Une erreur inattendue s'est produite. Nos equipes ont ete notifiees.</p>
<a href="/" style="padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;display:inline-block;" class="font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Retour a l'accueil
</a>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Tests\EventListener;
use App\EventListener\ExceptionListener;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Twig\Environment;
class ExceptionListenerTest extends TestCase
{
public function testDoesNothingInDevEnvironment(): void
{
$twig = $this->createMock(Environment::class);
$twig->expects(self::never())->method('render');
$listener = new ExceptionListener($twig, 'dev');
$event = $this->createEvent(new \RuntimeException('test'));
$listener($event);
self::assertNull($event->getResponse());
}
public function testRenders404Template(): void
{
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())
->method('render')
->with('error/404.html.twig', ['status_code' => 404])
->willReturn('<h1>404</h1>');
$listener = new ExceptionListener($twig, 'prod');
$event = $this->createEvent(new NotFoundHttpException('Not found'));
$listener($event);
self::assertNotNull($event->getResponse());
self::assertSame(404, $event->getResponse()->getStatusCode());
self::assertSame('<h1>404</h1>', $event->getResponse()->getContent());
}
public function testRenders500TemplateForGenericException(): void
{
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())
->method('render')
->with('error/500.html.twig', ['status_code' => 500])
->willReturn('<h1>500</h1>');
$listener = new ExceptionListener($twig, 'prod');
$event = $this->createEvent(new \RuntimeException('Server error'));
$listener($event);
self::assertNotNull($event->getResponse());
self::assertSame(500, $event->getResponse()->getStatusCode());
}
public function testRenders500TemplateForOtherHttpCodes(): void
{
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())
->method('render')
->with('error/500.html.twig', ['status_code' => 403])
->willReturn('<h1>403</h1>');
$listener = new ExceptionListener($twig, 'prod');
$event = $this->createEvent(new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException('Forbidden'));
$listener($event);
self::assertNotNull($event->getResponse());
self::assertSame(403, $event->getResponse()->getStatusCode());
}
private function createEvent(\Throwable $exception): ExceptionEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new ExceptionEvent($kernel, new Request(), HttpKernelInterface::MAIN_REQUEST, $exception);
}
}