Le programme d’un cinéma avec Laravel, en cache et testé

Un service, une route, une vue Blade : les séances de la semaine d’un cinéma avec les liens de réservation, en cache avec Cache::flexible pour survivre à une panne de l’API, et un test sans quota.

6 min de lecture

Sur cette page
  1. Ce qu’il vous faut
  2. 1.La clé
  3. 2.Le service
  4. 3.La route
  5. 4.La vue
  6. 5.Un test qui ne dépense pas de quota
  7. Pour aller plus loin

Le site d’un cinéma, la page culture d’une ville, le site d’un ciné-club : tous ont besoin de la même page, le programme des jours qui viennent avec les liens de réservation, à jour sans que personne ne le tape. Ce tutoriel la construit avec Laravel : une classe de service qui lit API Cinéma et garde la réponse en cache, une route, une vue Blade, et un test qui tourne sans dépenser une seule requête.

Comoedia
13 Av Berthelot, Lyon

Lundi 9 novembre
  Quelques mots d'amour 1 h 37    15:45 VF   18:10 VF   20:45 VF
  Wild Horse Nine                 15:50 VOST 18:20 VOST 20:50 VOST
  Club Kid 2 h 06                 16:00 VOST 18:30 VOST 21:00 VOST
Mardi 10 novembre
  …

Ce qu’il vous faut

  • Une application Laravel, version 11.23 ou plus récente pour Cache::flexible (ce tutoriel a tourné sur Laravel 13 et PHP 8.5). Une nouvelle : composer create-project laravel/laravel cinema.
  • Une clé API Cinéma : créez un compte gratuit, confirmez votre adresse e-mail, puis créez une clé depuis le tableau de bord. Le forfait gratuit donne 100 requêtes par semaine ; avec le cache ci-dessous, une page interroge l’API huit fois par jour au plus, quelle que soit son audience.
  • Le slug du cinéma. Cherchez-le par son nom :
Terminal
curl "https://api.api-cinema.com/v1/cinemas?q=comoedia" -H "Authorization: Bearer ac_live_…"

La réponse donne "slug": "comoedia-lyon". N’importe quel cinéma de l’API marche : France, États-Unis, Australie.

La clé

La clé va dans .env, jamais dans le code ni dans Git :

.env
API_CINEMA_KEY=ac_live_…

et se lit via config/services.php, à côté des autres services tiers :

config/services.php
'api_cinema' => [
    'key' => env('API_CINEMA_KEY'),
],

Le service : app/Services/ApiCinema.php

Une méthode publique, programme($slug) : le cinéma, et ses séances des sept prochains jours, rangées par jour puis par film.

app/Services/ApiCinema.php
<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class ApiCinema
{
    /**
     * Un cinéma et son programme des jours qui viennent, par jour puis par film.
     * Frais pendant 3 heures ; jusqu’à un jour, il est encore servi tout de suite et rafraîchi après la réponse.
     */
    public function programme(string $slug, int $days = 7): array
    {
        $programme = Cache::flexible("api-cinema:$slug:$days", [3 * 3600, 24 * 3600], fn () => $this->fetch($slug, $days));

        // le cache peut dater de quelques heures : retirer les séances commencées, à l’heure du cinéma
        $now = now($programme['cinema']['timezone'])->format('Y-m-d\TH:i');
        $programme['days'] = collect($programme['days'])
            ->map(fn ($films) => collect($films)
                ->map(fn ($film) => [...$film, 'showtimes' => array_filter($film['showtimes'], fn ($s) => $s['startsAt'] >= $now)])
                ->filter(fn ($film) => $film['showtimes'])
                ->sortBy(fn ($film) => min(array_column($film['showtimes'], 'startsAt'))))
            ->filter(fn ($films) => $films->isNotEmpty());

        return $programme;
    }

    private function fetch(string $slug, int $days): array
    {
        // du début d’aujourd’hui à la fin du dernier jour, à l’heure locale du cinéma
        $response = $this->api()->get("cinemas/$slug/showtimes", [
            'to' => now()->addDays($days - 1)->toDateString(),
            'include' => 'movie,cinema',
            'limit' => 1000, // une semaine d’un multiplexe de 17 salles fait moins de 300 séances
        ]);
        abort_if($response->notFound(), 404);
        $showtimes = $response->throw()->json('data');

        $programme = [];
        foreach ($showtimes as $s) {
            $day = substr($s['startsAt'], 0, 10);
            $programme[$day][$s['movie']['id']]['movie'] ??= $s['movie'];
            $programme[$day][$s['movie']['id']]['showtimes'][] = [
                'startsAt' => $s['startsAt'], // l’heure affichée au cinéma, par ex. 2026-11-12T20:30:00
                'time' => substr($s['startsAt'], 11, 5),
                'version' => $s['format'] === '2D' ? $s['version'] : "{$s['version']} {$s['format']}",
                'url' => $s['bookingUrl'],
            ];
        }

        return [
            // rien à l’affiche ces jours-ci : le cinéma lui-même, pour son nom
            'cinema' => $showtimes[0]['cinema'] ?? $this->api()->get("cinemas/$slug")->throw()->json(),
            'days' => $programme,
        ];
    }

    private function api(): PendingRequest
    {
        return Http::baseUrl('https://api.api-cinema.com/v1')
            ->withToken(config('services.api_cinema.key'))
            ->acceptJson()
            ->timeout(10);
    }
}

Ce qu’il fait :

  • Une requête par rafraîchissement. GET /v1/cinemas/{slug}/showtimes avec include=movie,cinema donne chaque séance avec son film (titre, durée, affiche) et son cinéma (nom, adresse, fuseau horaire). Une semaine d’un multiplexe de 17 salles fait moins de 300 séances, bien en dessous de limit=1000.
  • Cache::flexible garde le programme frais pendant 3 heures. Entre 3 et 24 heures, il est encore servi tout de suite, et Laravel redemande l’API une fois la réponse envoyée : aucun visiteur n’attend l’API, et si l’API ne répond pas, la page garde le dernier programme. Seul un cinéma que personne n’a regardé depuis un jour attend une requête fraîche.
  • Ce qui a commencé disparaît. Le cache peut dater de trois heures : les séances sont filtrées à chaque affichage selon l’heure qu’il est dans le fuseau du cinéma (startsAt est l’heure affichée au cinéma, 2026-11-12T20:30:00), et les films sont triés par leur prochaine séance.
  • Un slug inconnu donne une 404. L’API répond 404 pour un cinéma qu’elle ne connaît pas ; abort_if en fait la page 404 de Laravel, et rien ne part dans le cache.

La route

Dans routes/web.php, Laravel injecte le service :

routes/web.php
<?php

use App\Services\ApiCinema;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/cinema/{slug}', fn (string $slug, ApiCinema $api) => view('showtimes', $api->programme($slug)))
    ->where('slug', '[a-z0-9-]+');

/cinema/comoedia-lyon affiche le Comoedia ; le where garde hors de l’URL de l’API tout ce qui n’est pas un slug.

La vue : resources/views/showtimes.blade.php

resources/views/showtimes.blade.php
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $cinema['name'] }} : séances</title>
    <style>
        body { font: 16px/1.5 system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; color: #222; }
        h2 { margin-top: 2rem; border-bottom: 1px solid #ddd; text-transform: capitalize; }
        .film { display: flex; gap: 1rem; margin: 1rem 0; }
        .film img { width: 4.5rem; border-radius: 4px; align-self: flex-start; }
        .film h3 { margin: 0 0 .4rem; font-size: 1.05rem; }
        .times a { display: inline-block; margin: 0 .4rem .4rem 0; padding: .15rem .5rem; border: 1px solid #ccc; border-radius: 4px; color: inherit; text-decoration: none; }
        .times small { color: #777; }
    </style>
</head>
<body>
    <h1>{{ $cinema['name'] }}</h1>
    <p>{{ $cinema['address'] }}, {{ $cinema['city'] }}</p>

    @forelse ($days as $day => $films)
        <h2>{{ \Illuminate\Support\Carbon::parse($day)->translatedFormat('l j F') }}</h2>
        @foreach ($films as $film)
            <div class="film">
                @if ($film['movie']['posterUrl'])
                    <img src="{{ $film['movie']['posterUrl'] }}" alt="" loading="lazy">
                @endif
                <div>
                    <h3>{{ $film['movie']['title'] }}
                        @if ($film['movie']['durationMin'])
                            <small>{{ intdiv($film['movie']['durationMin'], 60) }} h {{ sprintf('%02d', $film['movie']['durationMin'] % 60) }}</small>
                        @endif
                    </h3>
                    <div class="times">
                        @foreach ($film['showtimes'] as $s)
                            <a @if ($s['url']) href="{{ $s['url'] }}" rel="nofollow" @endif>{{ $s['time'] }} <small>{{ $s['version'] }}</small></a>
                        @endforeach
                    </div>
                </div>
            </div>
        @endforeach
    @empty
        <p>Aucune séance annoncée pour les jours qui viennent.</p>
    @endforelse

    <p><small>Séances : <a href="https://api-cinema.com">API Cinéma</a></small></p>
</body>
</html>

Chaque valeur passe par {{ }}, qui l’échappe : titres et adresses viennent de l’extérieur. Les noms des jours suivent la langue de l’application : mettez APP_LOCALE=fr dans .env pour lundi 9 novembre au lieu de Monday 9 November.

Lancez l’application (php artisan serve) et ouvrez http://localhost:8000/cinema/comoedia-lyon. Le premier affichage prend moins d’une seconde ; les suivants sortent du cache en quelques millisecondes.

Un test qui ne dépense pas de quota

Http::fake répond à la place de l’API, et l’horloge est figée : le test dit la même chose tous les jours.

tests/Feature/ShowtimesPageTest.php
<?php

namespace Tests\Feature;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class ShowtimesPageTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Http::preventStrayRequests();
        $this->travelTo(Carbon::parse('2026-11-12 15:00', 'Europe/Paris'));
    }

    private function showtime(string $startsAt, string $title): array
    {
        return [
            'startsAt' => $startsAt, 'version' => 'VOST', 'format' => '2D', 'bookingUrl' => 'https://example.org/book',
            'movie' => ['id' => $title, 'title' => $title, 'durationMin' => 119, 'posterUrl' => null],
            'cinema' => ['name' => 'Comoedia', 'address' => '13 Avenue Berthelot', 'city' => 'Lyon', 'timezone' => 'Europe/Paris'],
        ];
    }

    public function test_it_shows_the_coming_showtimes_and_asks_the_api_once(): void
    {
        Http::fake(['api.api-cinema.com/v1/cinemas/comoedia-lyon/showtimes*' => Http::response(['data' => [
            $this->showtime('2026-11-12T11:00:00', 'Persona'),
            $this->showtime('2026-11-12T20:30:00', 'Safe'),
        ]])]);

        $this->get('/cinema/comoedia-lyon')
            ->assertOk()
            ->assertSee('Comoedia')
            ->assertSee('20:30')
            ->assertDontSee('Persona'); // 11:00 a déjà commencé
        $this->get('/cinema/comoedia-lyon')->assertOk();

        Http::assertSentCount(1); // le deuxième affichage vient du cache
    }

    public function test_the_last_programme_stays_up_when_the_api_is_down(): void
    {
        Http::fakeSequence('api.api-cinema.com/*')
            ->push(['data' => [$this->showtime('2026-11-12T20:30:00', 'Safe')]])
            ->push(['error' => ['code' => 'unavailable']], 503);

        $this->get('/cinema/comoedia-lyon')->assertSee('Safe');
        $this->travel(4)->hours(); // passé les 3 heures fraîches : cet affichage lance un rafraîchissement, qui échoue

        $this->get('/cinema/comoedia-lyon')->assertOk()->assertSee('Safe');
        Http::assertSentCount(2);
    }

    public function test_an_unknown_cinema_is_a_404(): void
    {
        Http::fake(['api.api-cinema.com/*' => Http::response(['error' => ['code' => 'not_found']], 404)]);

        $this->get('/cinema/no-such-cinema')->assertNotFound();
    }
}
Terminal
php artisan test --filter=ShowtimesPageTest

Les trois tests vérifient ce qui compte sur un site en ligne : une séance commencée n’apparaît pas, deux affichages coûtent une requête, le programme survit à une panne de l’API, et un mauvais slug donne une 404.

Pour aller plus loin

  • Plusieurs cinémas, ou moins de jours. La route accepte déjà n’importe quel slug ; programme($slug, 3) affiche trois jours au lieu de sept. Chaque cinéma affiché coûte huit requêtes par jour au plus.
  • Un cache toujours chaud. Pour un site très visité, rafraîchissez le programme avant qu’on le demande : dans routes/console.php, Schedule::call(fn () => app(ApiCinema::class)->programme('comoedia-lyon'))->everyThreeHours();, avec le planificateur lancé (php artisan schedule:work, ou la ligne cron habituelle).
  • Un point d’accès JSON pour un widget. Route::get('/api/showtimes/{slug}', fn (string $slug, ApiCinema $api) => $api->programme($slug)) donne les mêmes données, en JSON, à un front ou une appli mobile, sans exposer la clé. Pour un site sans Laravel, le tutoriel HTML et JavaScript a un widget pour le navigateur et un relais en PHP simple de 50 lignes.
  • Des filtres. Ajoutez 'version' => 'VOST' à la requête pour la VO seulement, ou gardez tout et filtrez dans la vue.

Tous les paramètres et champs sont dans la documentation. Sur WordPress, le tutoriel WordPress fait la même chose avec une extension et un shortcode.