Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
FullCalendar (partie 1)
Mardi 9 décembre 2025 17:34

J’avais déjà rédigé un article sur FullCalendar pour ce blog, mais celui-ci commence à dater. J’ai récemment dû l'explorer à nouveau dans le cadre d’un nouveau projet. Il s’agit d’une bibliothèque extrêmement complète, offrant une présentation esthétique du calendrier sous différentes formes, avec la possibilité d’intégrer des événements. Elle est entièrement configurable, gratuite et open source pour la majorité de ses fonctionnalités. Sur le site officiel, on trouve de nombreuses démonstrations : glisser-déposer d’événements, création d’événements en cliquant sur un jour, affichage d’événements en arrière-plan, changement de thème ou de langue… En résumé, tout ce qu’il faut pour concevoir une application basée sur la gestion d’événements temporels. Je me base sur la dernière version actuelle de FullCalendar : 6.1.19.

FullCalendar est une bibliothèque frontend basée sur JavaScript, ce qui signifie qu’elle ne gère pas la persistance des données. Livewire, quant à lui, est particulièrement efficace pour gérer ce type de scénarios sans que vous ayez à vous soucier de la gestion des requêtes Ajax. Dans cet article, nous verrons comment orchestrer l’intégration des deux.

Vous pouvez télécharger le code final de cet article ici.

Installation

Laravel

On fait une nouvelle installation de Laravel en utilisant, pourquoi pas, mon starter kit :

laravel new agenda --using=bestmomo/simple-tall-starter-kit

Choisissez l'outil de test que vous voulez et compilez les assets.

Vous n'êtes évidemment pas obligé d'utiliser ce kit. Retenez juste que vous aurez besoin d'une authentification et de Livewire.

Créez une base de données et informez le fichier .env (vous pouvez aussi opter pour sqlite) :

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=agenda
DB_USERNAME=root
DB_PASSWORD=

On va aussi fixer la locale et le time zone :

APP_LOCALE=fr
APP_TIMEZONE=Europe/Paris

On aura besoin de quelques utilisateurs, alors dans DatabaseSeeder, prévoyez ce code :

public function run(): void
{
    User::factory(3)->create();
    echo "Création de " . User::count() . " utilisateurs\n";
}

On peut alors créer les tables de base :

php artisan migrate --seed

Vous disposez de Livewire avec l'installation. Vous avez également l'essentiel de l'authentification. Par contre, vous n'avez pas encore de Layout de base pour Livewire, on va donc le créer :

php artisan livewire:layout

Les bonnes habitudes

On n'a pas forcément de bonnes habitudes quand on code. On a même plutôt tendance à en prendre des mauvaises. Dans cet esprit, je vous propose de compléter l'installation avec ce package :

composer require nunomaduro/essentials

Il propose de meilleures configurations par défaut pour les applications Laravel, notamment :

  • Des modèles stricts (strict models) pour éviter les erreurs d’attributs non définis ou les affectations invalides.
  • Le chargement automatique (eager loading) des relations Eloquent, ce qui optimise les performances et évite les problèmes de lazy loading.
  • Des dates immutables par défaut, pour une gestion plus sûre des dates.
  • Une configuration Pint améliorée et un nettoyage du squelette de projet.
  • D’autres options configurables pour des patterns Laravel courants.

On verra dans cet article comment l'exploiter pour obtenir un code bien propre.

Un composant Livewire

On crée un composant LiveWire :

php artisan make:livewire calendar
 COMPONENT CREATED 

CLASS: app/Livewire//Calendar.php
VIEW:  E:\laragon\www\agenda\resources\views/livewire/calendar.blade.php

Cela génère deux fichiers : une classe PHP et une vue Blade :

Route et layout

On a créé précédemment le layout pour notre composant, on va l'adapter à nos besoins pour accueillir notre calendrier :

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Mon agenda</title>
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body class="min-h-screen bg-gray-100 antialiased dark:bg-gray-900">
        <div class="p-4 sm:p-6 lg:p-8">
            {{ $slot }}
        </div>
        @stack('scripts')
    </body>
</html>

Ce layout servira de base pour afficher le calendrier et gérer les scripts nécessaires.

On crée aussi la route pour afficher notre vue :

use App\Livewire\Calendar;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::get('/', Calendar::class)->name('home');
});

On réserve l'accès aux utilisateurs authentifiés (middleware auth), on crée un groupe pour les autres routes dont on aura besoin.

Le calendrier

Maintenant, on peut se lancer en créant le premier calendrier. On code le composant Livewire :

<div>
    <h1 class="text-3xl font-bold text-gray-900 mb-6">
        Mon agenda
    </h1>

    <div class="max-w-6xl mx-auto" wire:ignore>
        <div id='calendar'></div>
    </div>

    @push('scripts')
        <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>

        <script>
            document.addEventListener('DOMContentLoaded', function () {
                const calendarEl = document.getElementById('calendar');

                const calendar = new FullCalendar.Calendar(calendarEl, {
                    initialView: 'dayGridMonth',
                    locale: '{{ config('app.locale') }}',
                    timeZone: '{{ config('app.timezone') }}',
                    headerToolbar: {
                        left: 'prev,next today',
                        center: 'title',
                        right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
                    },
                });
                calendar.render();
            });
        </script>
    @endpush
</div>

On utilise le CDN de FullCalendar pour le charger rapidement. Ensuite, on a un simple conteneur et la prestation minimale pour le calendrier.

Pourquoi wire:ignore ?

FullCalendar génère son propre DOM complexe avec des événements, des cellules, etc. Sans wire:ignore :

  • Livewire essaierait de "diff" et mettre à jour le DOM du calendrier à chaque re-rendu

  • Cela briserait l'état interne de FullCalendar

  • Le calendrier pourrait être réinitialisé ou afficher des comportements inattendus

Pourquoi utiliser DomContentLoaded ?

  • Assure que tout le DOM est chargé avant d'initialiser FullCalendar

  • Sans cela, si le script s'exécute avant que #calendar existe, vous auriez une erreur

Pour le moment Livewire ne nous sert à rien, mais c'est juste pour voir si le calendrier s'affiche correctement :

On a les réglages par défaut : affichage du mois courant et boutons pour le défilement par mois. On a aussi la possibilité de basculer sur les semaines, les jours, ou le mode liste. On a prévu la locale, mais on a seulement le mois et les jours qui sont corrects en français. Par contre, on va devoir aussi traduire les boutons.

On a déjà un fichier pour accueillir le code Javascript de notre projet :

On va placer dans ce fichier nos traductions :

!function(){
    FullCalendar.globalLocales.push(
        {
            code:"fr",
            week:{
                dow:1,
                doy:4
            },
            buttonText:{
                prev:"Précédent",
                next:"Suivant",
                today:"Aujourd'hui",
                year:"Année",
                month:"Mois",
                week:"Semaine",
                day:"Jour",
                list:"Planning"
            },
            weekText:"Sem.",
            weekTextLong:"Semaine",
            allDayText:"Toute la journée",
            moreLinkText:"en plus",
            noEventsText:"Aucun évènement à afficher"
        }
    )
}();

Il ne faut pas oublier de recompiler avec npm si on n'est pas en mode dev automatique. Maintenant nos boutons sont en français :

Mais notre calendrier est vide et on n'a encore aucun moyen de le remplir...

Persistance des données

À présent qu'on sait comment créer un calendrier, il va falloir le remplir. Côté Laravel, on crée une table (avec modèle, factory et seeder) pour mémoriser les événements :

php artisan make:model Event -msf

Pour les colonnes de notre table, il faut déjà savoir quelles sont les informations utilisées par FullCalendar pour ses événements. On trouve l'objet Event dans la documentation. On se rend compte qu'il y a beaucoup de possibilités. On va prévoir :

  • id : l'identifiant de l'événement
  • title : le titre de l'événement tel qu'il apparaît
  • start_date : date et heure éventuelle de début de l'événement
  • end_date : date et heure de fin de l'événement (si null c'est que l'événement concerne la journée entière)
  • is_all_day : si l'événement a lieu toute la journée
  • description : pour en savoir plus sur l'événement
  • color :  pour distinguer les types d'événements

On code en conséquence la migration :

public function up(): void
{
    Schema::create('events', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->foreignId('user_id')->constrained()->onDelete('cascade');
        $table->string('title');
        $table->text('description')->nullable();
        $table->string('color', 7)->nullable();
        $table->string('location')->nullable();
        $table->dateTime('start_date');
        $table->dateTime('end_date')->nullable();
        $table->boolean('is_all_day')->default(false);
        $table->timestamps();
    });
}

Avec Eloquent, on n'est pas obligé d'avoir une clé primaire en nombre entier auto-incrémenté. J'utilise un uuid. Il serait délicat d'utiliser dans le calendrier des entiers en série continue comme identifiant et ensuite de les gérer simplement.

Les modèles

Event

Dans le modèle Event, on prévoit l'assignement de masse, que notre clé n'est pas un entier auto-incrémenté, que les dates soient bien transformées pour Carbon, on ajoute aussi la relation avec les utilisateurs :

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Event extends Model
{
    use HasFactory;
    
    protected $fillable = [
        'id',
        'title',
        'description',
        'start_date',
        'end_date',
        'is_all_day',
        'user_id',
        'color',
        'location',
    ];

    protected $casts = [
        'start_date' => 'datetime',
        'end_date' => 'datetime',
        'is_all_day' => 'boolean',
    ];

    public $incrementing = false;
    protected $keyType = 'string';

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

User

Dans le modèle User, on va se contenter d'ajouter la relation :

use Illuminate\Database\Eloquent\Relations\HasMany;

public function events(): HasMany
{
    return $this->hasMany(Event::class);
}

La factory et le seeder des événements

On s'arrange pour obtenir des événements assez variés :

<?php

namespace Database\Factories;

use App\Models\Event;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
 */
class EventFactory extends Factory
{
    protected $model = Event::class;

    public function definition(): array
    {
        $start = Carbon::today()->addDays(rand(0, 30));
        $isAllDay = $this->faker->boolean(25);

        if ($isAllDay) {
            if ($this->faker->boolean(50)) {
                $daysDuration = rand(2, 6);
                $end = (clone $start)->addDays($daysDuration);
                $startFormatted = $start->toDateString();
                $endFormatted = $end->toDateString();
            } else {
                $startFormatted = $start->toDateString();
                $endFormatted = (clone $start)->addDay()->toDateString();
            }
        } else {
            $start->addHours(rand(9, 17));
            $end = (clone $start)->addHours(rand(1, 3));
            $startFormatted = $start->toDateTimeString();
            $endFormatted = $end->toDateTimeString();
        }

        $colors = ['#3788d8', '#ff9f89', '#46d84a', '#d83769'];

        return [
            'id' => Str::uuid(),
            'title' => $this->faker->catchPhrase(),
            'description' => $this->faker->paragraph(2),
            'color' => $this->faker->randomElement($colors),
            'location' => $this->faker->boolean(70) ? $this->faker->city() : null,
            'start_date' => $startFormatted,
            'end_date' => $endFormatted,
            'is_all_day' => $isAllDay,
        ];
    }
}

Dans le seeder, on crée des événements et on les lie avec les utilisateurs :

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Event;

class EventSeeder extends Seeder
{
    public function run(): void
    {
        $users = User::all();
        $users->each(function (User $user) {
            Event::factory()
                ->count(rand(5, 15))
                ->create([
                    'user_id' => $user->id,
                ]);
        });

        echo "Création de " . Event::count() . " événements liés aux " . $users->count() . " utilisateurs\n";
    }
}

Dans Databaseeder, il faut appeler la classe créée :

public function run(): void
{
    ...
    $this->call(EventSeeder::class);
}

On lance :

php artisan migrate:fresh --seed                                                                                                               
                                                                                                                                                 
  Dropping all tables ............................................................................................................. 31.08ms DONE 
                                                                                                                                                 
   INFO  Preparing database.                                                                                                                     
                                                                                                                                                 
  Creating migration table ......................................................................................................... 9.63ms DONE 
                                                                                                                                                 
   INFO  Running migrations.                                                                                                                     
                                                                                                                                                 
  0001_01_01_000000_create_users_table ............................................................................................. 32.12ms DONE 
  0001_01_01_000001_create_cache_table ............................................................................................ 10.51ms DONE 
  0001_01_01_000002_create_jobs_table ................................................................................................ 27.16ms DONE 
  2025_09_02_075243_add_two_factor_columns_to_users_table .................................................. 29.55ms DONE 
  2025_12_08_174418_create_events_table ........................................................................................... 25.54ms DONE 
                                                                                                                                                 
                                                                                                                                                 
   INFO  Seeding database.                                                                                                                       
                                                                                                                                                 
Création de 3 utilisateurs                                                                                                                       
  Database\Seeders\EventSeeder ......................................................................................................... RUNNING 
Création de 22 événements liés aux 3 utilisateurs                                                                                                
  Database\Seeders\EventSeeder ...................................................................................................... 55 ms DONE 

Les événements pour FullCalendar

Il y a plusieurs manières d'envoyer des événements à FullCalendar. La plus simple est de générer un tableau :

var calendar = new Calendar(calendarEl, {
  events: [
    {
      title  : 'event1',
      start  : '2010-01-01'
    },
    {
      title  : 'event2',
      start  : '2010-01-05',
      end    : '2010-01-07'
    }
  ]
});

Dans notre cas, il suffirait donc de récupérer tous les événements de l'utilisateur connecté et de les envoyer dans le calendrier. C'est simple, mais pas trop optimisé s'il y a beaucoup d'enregistrements. Il serait plus efficace de charger juste les événements qui doivent être affichés. Lorsqu'on lit la documentation de FullCalendar, on se rend compte qu'il y a déjà la solution implémentée :

var calendar = new Calendar(calendarEl, {
  events: '/myfeed.php'
});

Il suffit donc de prévoir une route avec la bonne source de données en JSON. Le format généré est du genre :

/myfeed.php?start=2013-12-01T00:00:00-05:00&end=2014-01-12T00:00:00-05:00

On s’écarte temporairement de Livewire pour optimiser le chargement des événements.

Un contrôleur pour les événements

On crée un contrôleur :

php artisan make:controller GetEvents

<?php

namespace App\Http\Controllers;

use App\Models\Event;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;

class GetEvents extends Controller
{
    public function __invoke(): JsonResponse
    {
        $startDate = Carbon::parse(request('start'));
        $endDate = Carbon::parse(request('end'));

        $events = Auth::user()->events()->inPeriod($startDate, $endDate)->get();

        $formattedEvents = $events->map(function (Event $event) {
            $isAllDay = (bool)$event->is_all_day;

            if (!$isAllDay) {
                $startDate = Carbon::parse($event->start_date)->toIso8601String();
                $endDate = Carbon::parse($event->end_date)->toIso8601String();
            } else {
                $startDate = Carbon::parse($event->start_date)->format('Y-m-d');
                $endDate = Carbon::parse($event->end_date)->addDay()->format('Y-m-d');
            }

            return [
                'id' => $event->id,
                'title' => $event->title,
                'start' => $startDate,
                'end' => $endDate,
                'allDay' => $isAllDay,
                'color' => $event->color,
                'extendedProps' => [
                    'description' => $event->description,
                    'location' => $event->location,
                ],
            ];
        });

        return response()->json($formattedEvents);
    }
}

Si vous êtes observateurs, vous avez remarqué que j'utilise une fonction inPeriod qui n'existe pas dans Eloquent. On l'ajoute comme scope locale dans le modèle Event :

use Illuminate\Database\Eloquent\Attributes\Scope;

#[Scope]
public function inPeriod($query, $start, $end)
{
    return $query->where('start_date', '<', $end)
        ->where('end_date', '>', $start);
}

On complète avec la route :

use App\Http\Controllers\GetEvents;

Route::middleware('auth')->group(function () {
    ...
    Route::get('/events', GetEvents::class)->name('events');
});

Si vous testez avec ce genre d'URL :

http://agenda.oo/events?start=2025-12-01T&end=2025-12-31

Vous devez récupérer les événements de la période concernée pour l'utilisateur authentifié au format JSON.

La gestion temporelle n'est pas vraiment de tout repos avec ce calendrier, surtout qu'on a deux types d'événements : ceux qui durent toute la journée (et éventuellement sur plusieurs jours) et ceux qui durent un certain temps dans la journée. 

Pour les événements "normaux" on utilise toIso8601String. Analysons le format ISO 8601 :

  • Format complet : 2024-01-15T14:30:00+01:00

  • Inclut : Date + Heure + Fuseau horaire

  • Pour FullCalendar :

    • Nécessite ce format pour les événements avec heure précise

    • Permet un affichage correct dans les vues timeGridDay, timeGridWeek

    • Gère automatiquement les fuseaux horaires

Par exemple pour un événement de 14h30 à 16h00 on aura le format : 2024-01-15T14:30:00+01:00

Par contre, pour un format sur toute la journée, on n'a pas besoin des heures et on utilise addDay.

Le calendrier modifié

On code la propriété events dans le calendrier :

events: '{{ route('events') }}',

Les événements devraient maintenant s’afficher automatiquement :

Lorsque vous changez de période, les événements s'actualisent automatiquement.

Si on veut peaufiner, on peut ajouter un petit effet d'opacité lors du chargement des événements :

loading: function(isLoading) {
    if (isLoading) {
        calendarEl.style.opacity = '0.6';
        calendarEl.style.pointerEvents = 'none';
    } else {
        calendarEl.style.opacity = '1';
        calendarEl.style.pointerEvents = 'auto';
    }
},

Ménage dans le code

Puisqu'on a installé des outils pour avoir un joli code, on va les utiliser.

Pint est un nettoyeur de code PHP. Il y a une documentation complète dans celle de Laravel. On va le lancer pour voir si on a bien travaillé :

 php ./vendor/bin/pint                                                                                                                          
                                                                                                                                                
 ....✓..✓...........✓............✓✓..✓✓✓✓✓✓✓✓✓✓✓✓..✓                                                                                            
                                                                                                                                                
 ───────────────────────────────────────────────── Laravel 
   FIXED   .......................................... 51 files, 18 style issues fixed 
 ✓ app\Http\Controllers\GetEvents.php                 cast_spaces, not_operator_with_successor_space, single_blank_line_at_eof 
 ✓ app\Models\Event.php                     class_attributes_separation 
 ✓ config\fortify.php                           array_indentation, single_line_comment_spacing 
 ✓ database\seeders\DatabaseSeeder.php                single_quote, concat_space 
 ✓ database\seeders\EventSeeder.php                   single_quote, concat_space, ordered_imports 
 ✓ routes\web.php                             no_extra_blank_lines, single_blank_line_at_eof 

On s'en sort pas trop mal, que des corrections de détail.

Conclusion

Dans cet article, nous avons commencé à voir comment gérer Fullcalendar avec Livewire. Pour l'instant, nous nous sommes contentés de créer un composant Livewire pour accueillir le calendrier, avec des réglages de base. Nous avons également mis en place un rafraîchissement intelligent des événements, de sorte que seuls ceux qui sont utiles sont chargés. Nous avons par ailleurs mis en place un système de filtrage des événements selon l'utilisateur authentifié. Maintenant que les bases sont solides, nous pourrons dans un prochain article nous intéresser à la gestion proprement dite des événements : visualisation, création, suppression et modification. Nous améliorerons aussi l'aspect du calendrier qui n'est pas vraiment esthétique en version de base. Nous verrons ensuite si nous ajoutons d'autres fonctionnalités.



Par bestmomo

Aucun commentaire