A cinema’s programme in Laravel, cached and tested

One service, one route, one Blade view: the week’s showtimes of a cinema with booking links, kept in the cache with Cache::flexible so the page survives an API outage, and a test that spends no quota.

6 min read

On this page
  1. What you need
  2. 1.The key
  3. 2.The service
  4. 3.The route
  5. 4.The view
  6. 5.A test that spends no quota
  7. Going further

A cinema's website, a town's culture page, a film society's site: they all need the same page, the programme of the coming days with the booking links, up to date without anyone typing it. This tutorial builds it in Laravel: one service class that reads API Cinéma and keeps the answer in the cache, one route, one Blade view, and a test that runs without spending a single request.

Comoedia
13 Av Berthelot, Lyon

Monday 9 November
  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
Tuesday 10 November
  …

What you need

  • A Laravel application, version 11.23 or later for Cache::flexible (this tutorial was run on Laravel 13 and PHP 8.5). A new one: composer create-project laravel/laravel cinema.
  • An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 100 requests a week; with the cache below, a page asks the API at most eight times a day, whatever its traffic.
  • The slug of the cinema. Search it by name:
Terminal
curl "https://api.api-cinema.com/v1/cinemas?q=comoedia" -H "Authorization: Bearer ac_live_…"

The answer gives "slug": "comoedia-lyon". Any cinema of the API works: France, the United States, Australia.

The key

The key goes in .env, never in the code or in Git:

.env
API_CINEMA_KEY=ac_live_…

and is read through config/services.php, next to the other third-party services:

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

The service: app/Services/ApiCinema.php

One public method, programme($slug): the cinema, and its showtimes for the next seven days grouped by day, then by 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
{
    /**
     * A cinema and its programme for the coming days, grouped by day then by film.
     * Fresh for 3 hours; up to a day old it is still served at once and refreshed after the response.
     */
    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));

        // the cache can be a few hours old: drop the showtimes that have started, in the cinema's own time
        $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
    {
        // from the start of today to the end of the last day, in the cinema's local time
        $response = $this->api()->get("cinemas/$slug/showtimes", [
            'to' => now()->addDays($days - 1)->toDateString(),
            'include' => 'movie,cinema',
            'limit' => 1000, // a week of a 17-screen multiplex is under 300
        ]);
        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'], // the cinema's wall-clock time, e.g. 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 [
            // nothing on in the coming days: the cinema itself, for its name
            '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);
    }
}

What it does:

  • One request per refresh. GET /v1/cinemas/{slug}/showtimes with include=movie,cinema gives each showtime with its film (title, runtime, poster) and its cinema (name, address, time zone). A week of a 17-screen multiplex is under 300 showtimes, well within limit=1000.
  • Cache::flexible keeps the programme fresh for 3 hours. Between 3 and 24 hours old, it is still served at once, and Laravel asks the API again after the response is sent: no visitor waits for the API, and if the API does not answer, the page keeps the last programme. Only a cinema not viewed for a whole day waits for a fresh request.
  • What has started disappears. The cache can be three hours old, so the showtimes are filtered at each view against the current time in the cinema's time zone (startsAt is the cinema's wall-clock time, 2026-11-12T20:30:00), and the films are sorted by their next showtime.
  • An unknown slug is a 404. The API answers 404 for a cinema it does not know; abort_if turns it into Laravel's 404 page, and nothing goes in the cache.

The route

In routes/web.php, the service is injected by Laravel:

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 shows the Comoedia; the where keeps anything that is not a slug out of the API URL.

The view: 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'] }}: showtimes</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>No showtimes announced for the coming days.</p>
    @endforelse

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

Every value goes through {{ }}, which escapes it: titles and addresses come from outside. The day names follow the application's locale: with APP_LOCALE=fr in .env, Monday 9 November becomes lundi 9 novembre.

Start the application (php artisan serve) and open http://localhost:8000/cinema/comoedia-lyon. The first view takes under a second; the next ones come from the cache in a few milliseconds.

A test that spends no quota

Http::fake answers in place of the API, and the clock is frozen, so the test says the same thing every day:

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 has started already
        $this->get('/cinema/comoedia-lyon')->assertOk();

        Http::assertSentCount(1); // the second page view came from the 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(); // past the 3 fresh hours: this view triggers a refresh, which fails

        $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

The three tests check what matters on a live site: a showtime that has started is not shown, two views cost one request, the programme survives an API outage, and a wrong slug is a 404.

Going further

  • Several cinemas, or a few days. The route already takes any slug; programme($slug, 3) shows three days instead of seven. Each cinema shown costs at most eight requests a day.
  • A warm cache. For a busy site, refresh the programme before anyone asks: in routes/console.php, Schedule::call(fn () => app(ApiCinema::class)->programme('comoedia-lyon'))->everyThreeHours();, with the scheduler running (php artisan schedule:work, or the usual cron line).
  • A JSON endpoint for a widget. Route::get('/api/showtimes/{slug}', fn (string $slug, ApiCinema $api) => $api->programme($slug)) gives the same data, as JSON, to a front end or a mobile app, without exposing the key. For a site without Laravel, the HTML and JavaScript tutorial has a browser widget and a 50-line plain PHP relay.
  • Filters. Add 'version' => 'VOST' to the query for the original versions only, or keep everything and filter in the view.

Every parameter and field is in the documentation. On WordPress, the WordPress tutorial does the same with a plugin and a shortcode.