Plugin Filament UI & Filament v1.0.0

Filament Business Hours

Confiez les horaires d'ouverture à votre client : grille hebdomadaire, fermetures exceptionnelles, congés et fuseaux horaires, stockés au format spatie/opening-hours.

Filament Business Hours

Compatibilité

PHP ^8.2
Laravel ^12.0|^13.0
Filament ^4.0|^5.0
spatie/opening-hours ^4.0
Version v1.0.0
Licence MIT

Installation

composer require laboiteacode/filament-business-hours

Présentation

Afficher « ouvert jusqu'à 18h » sur un site, fermer la boutique entre Noël et le Nouvel An, poser trois semaines de congés en août : ces horaires finissent presque toujours codés en dur quelque part, dans un tableau PHP que seul le développeur sait modifier. Filament Business Hours les rend à leur propriétaire, dans son panel d'administration.

La page affiche une grille hebdomadaire sur 24 heures, façon agenda. On glisse sur une journée pour créer une plage, on la déplace, on étire ses bords pour la redimensionner. Tout aimante sur un pas configurable, et un clic sur une plage ouvre un éditeur précis à la minute. À côté de la grille, deux sections traitent les cas particuliers : les journées exceptionnelles, fermées ou ouvertes sur des horaires spéciaux, et les périodes de vacances.

Le format de stockage est celui de spatie/opening-hours

Rien n'est réinventé : ce que la page écrit en base est exactement la définition attendue par spatie/opening-hours. Pas d'étape d'export, pas de format maison à reconvertir. Une ligne suffit pour interroger, depuis n'importe où dans l'application, les horaires saisis dans le panel :

BusinessHours::openingHours()?->isOpenAt(now());

Toute l'API de la librairie s'applique ensuite (nextOpen(), forDate(), exceptionalClosingDates()), avec le fuseau horaire et les libellés saisis côté panel.

Ce que ça change au quotidien

  • Plus de ticket pour décaler une fermeture : la personne qui tient l'établissement modifie ses horaires elle-même, sans déploiement.
  • Rien n'entre en base sans être validé : à l'enregistrement, la définition complète est vérifiée en instanciant réellement l'objet spatie. Une ligne stockée est, par construction, une ligne interrogeable.
  • Plusieurs jeux d'horaires cohabitent dans la même table, un par établissement ou par boutique, chacun repéré par sa clé.

Pensé pour tenir en production

L'état de la page est modifiable côté client, il est donc traité comme tel : plafonds stricts (100 plages par jour, 500 journées exceptionnelles et périodes de vacances, 1100 jours par période, 255 caractères par libellé), validation du fuseau horaire côté serveur, liste blanche d'assignation de masse sur le modèle, et rendu défensif pour qu'un état malformé dégrade l'affichage au lieu de casser la page.

Une date couverte deux fois, par exemple une fermeture exceptionnelle tombant à l'intérieur d'une période de congés, est refusée avec un message lisible plutôt qu'écrasée en silence. Les plages se créent aussi au clavier depuis l'en-tête de chaque jour, et la grille reprend la palette de couleurs de votre panel.

Le plugin est distribué sous licence MIT, et livré avec les traductions française, anglaise et espagnole.

Documentation

Installation

Installez le package avec Composer :

composer require laboiteacode/filament-business-hours

Publiez puis jouez la migration, qui crée l'unique table business_hours :

php artisan vendor:publish --tag=filament-business-hours-migrations
php artisan migrate

Enregistrez enfin le plugin sur chaque panel où la page doit apparaître, en général dans app/Providers/Filament/AdminPanelProvider.php :

use LaBoiteACode\FilamentBusinessHours\FilamentBusinessHoursPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(FilamentBusinessHoursPlugin::make());
}

C'est terminé : une entrée Horaires d'ouverture apparaît dans la navigation du panel.

Assets

Le CSS et le JS de la grille sont enregistrés automatiquement auprès de Filament. En production, publiez-les comme n'importe quel asset Filament :

php artisan filament:assets

Fichiers publiables

Les trois autres groupes publiables sont facultatifs :

# config/filament-business-hours.php
php artisan vendor:publish --tag="filament-business-hours-config"

# lang/vendor/filament-business-hours/{locale}/filament-business-hours.php
php artisan vendor:publish --tag="filament-business-hours-translations"

# resources/views/vendor/filament-business-hours/
php artisan vendor:publish --tag="filament-business-hours-views"

Utilisation

La grille hebdomadaire

Sept colonnes de cellules horaires, empilées de 00:00 à 24:00. Les plages se manipulent directement à la souris, et tout aimante sur le pas configuré (15 minutes par défaut).

Action Comment
Créer une plage Glisser verticalement sur une journée, ou cliquer pour un bloc d'une heure
Déplacer une plage La glisser
Redimensionner une plage Glisser son bord haut ou bas
Saisir des horaires précis Cliquer la plage, clic droit, ou Entrée quand elle a le focus
Supprimer une plage Le bouton de l'éditeur, ou Suppr sur une plage ciblée
Créer une plage au clavier Le bouton Ajouter une plage de l'en-tête du jour
Copier une journée Copier ce jour vers… dans l'en-tête, puis cocher les jours cibles
Vider une journée Vider ce jour dans l'en-tête

Une cellule partiellement couverte porte un petit badge de minutes : « 30 » sur la cellule 05:00 signifie que la plage commence à 05:30. L'éditeur précis accepte n'importe quelle minute, quel que soit le pas d'aimantation, et les plages qui se chevauchent ou se touchent sont fusionnées automatiquement à l'enregistrement. Chaque en-tête de jour affiche son total d'heures.

Journées exceptionnelles

Une date unique, fermée toute la journée ou ouverte sur des horaires particuliers, avec un libellé facultatif (« Noël », « inventaire ») et une option Répéter chaque année qui ignore l'année de la date choisie.

Périodes de vacances

Une plage de dates pendant laquelle l'établissement est fermé, avec un libellé. Elle est stockée comme une exception de type période, native chez spatie.

Réglages

Le bouton Réglages ouvre le fuseau horaire dans lequel les horaires sont exprimés, et l'option Plages après minuit.

Plages après minuit

La grille raisonne par journée, de 00:00 à 24:00. Un établissement de nuit qui ferme à 2 h du matin se modélise en général avec deux plages (20:00-24:00 le vendredi et 00:00-02:00 le samedi). Si vous préférez le mode overflow de spatie, activez Plages après minuit dans les réglages : les plages du type 20:00-02:00 sont alors acceptées, affichées tronquées à minuit dans la grille, et modifiables via l'éditeur précis.

Enregistrement et validation

L'enregistrement est explicite : l'action Enregistrer de l'en-tête persiste toute la page (grille, journées exceptionnelles, périodes de vacances et réglages) en un seul enregistrement. Avant d'écrire, la page :

  • normalise chaque plage (format, tri chronologique, fusion des plages qui se chevauchent ou se touchent) ;
  • refuse les dates couvertes deux fois, par exemple une fermeture exceptionnelle tombant dans une période de congés, avec un message qui nomme les deux entrées en cause ;
  • valide le fuseau horaire ;
  • valide la charge utile complète en instanciant réellement Spatie\OpeningHours\OpeningHours.

Des plafonds stricts protègent en plus le serveur, l'état Livewire étant modifiable côté client : 100 plages par jour, 500 journées exceptionnelles et périodes de vacances au total, 1100 jours par période et 255 caractères par libellé.

Interroger les horaires

En une ligne

use LaBoiteACode\FilamentBusinessHours\Models\BusinessHours;

$openingHours = BusinessHours::openingHours();             // l'enregistrement « default »
$openingHours = BusinessHours::openingHours('shop-paris'); // un autre enregistrement

// null tant qu'aucun enregistrement n'a été sauvegardé sous cette clé.

Tout ce que spatie sait répondre

Ce que vous récupérez est une instance Spatie\OpeningHours\OpeningHours ordinaire : toute l'API de la librairie s'applique aux horaires gérés depuis le panel.

$openingHours->isOpenAt(new DateTime('2026-12-24 11:00')); // horaires spéciaux appliqués
$openingHours->isOpenOn('monday');

$openingHours->nextOpen(now());   // prochaine ouverture
$openingHours->nextClose(now());  // jusqu'à quand sommes-nous ouverts

$openingHours->forDay('monday');                    // les plages hebdomadaires d'un jour
$openingHours->forDate(new DateTime('2026-12-25')); // une date concrète, exceptions appliquées
$openingHours->forWeek();

$openingHours->exceptionalClosingDates();           // congés et fermetures, dépliés

Le fuseau horaire choisi dans le panel fait partie de la définition : les requêtes de date et d'heure sont donc interprétées dans ce fuseau automatiquement. Les libellés saisis dans le panel voyagent en tant que data spatie et reviennent par $openingHours->forDate(...)->data.

Depuis un enregistrement

$record = BusinessHours::resolve('default');

$record->toOpeningHours();      // Spatie\OpeningHours\OpeningHours
$record->toOpeningHoursArray(); // le tableau de définition brut

toOpeningHoursArray() renvoie une définition spatie sans surcouche : horaires hebdomadaires, exceptions (dates simples, dates annuelles au format m-d, périodes Y-m-d to Y-m-d), le drapeau overflow et le fuseau horaire.

[
    'monday' => ['09:00-12:00', '14:00-18:00'],
    // ...
    'exceptions' => [
        '2026-12-24' => ['10:00-16:00'],                                    // horaires spéciaux
        '2026-12-25' => ['hours' => [], 'data' => 'Noël'],                  // fermé, libellé
        '01-01'      => [],                                                 // fermé chaque année
        '2026-08-03 to 2026-08-16' => ['hours' => [], 'data' => 'Congés'],  // période de vacances
    ],
    'timezone' => 'Europe/Paris',
]

Sans le modèle

Les colonnes elles-mêmes étant déjà au format spatie, rien ne vous oblige à passer par le modèle Eloquent. N'importe quel code capable de lire la table peut assembler la définition.

use Illuminate\Support\Facades\DB;
use Spatie\OpeningHours\OpeningHours;

$row = DB::table('business_hours')->where('key', 'default')->first();

$data = json_decode($row->hours, true)
    + ['exceptions' => json_decode($row->exceptions, true)];

if ($row->overflow) {
    $data['overflow'] = true;
}

if ($row->timezone) {
    $data['timezone'] = $row->timezone;
}

$openingHours = OpeningHours::create($data);

Mettre en cache les chemins chauds

OpeningHours::create() analyse toute la définition à chaque appel. Sur une page sollicitée à chaque requête, un en-tête de boutique affichant « ouvert jusqu'à 18:00 » par exemple, mettez le tableau de définition en cache plutôt que de le reconstruire :

$data = cache()->remember(
    'business-hours:default',
    now()->addHour(),
    fn (): array => BusinessHours::resolve('default')->toOpeningHoursArray(),
);

$openingHours = OpeningHours::create($data);

Invalidez la clé à l'enregistrement de la ligne (un observer sur BusinessHours), ou contentez-vous d'une durée de vie courte comme ci-dessus.

Écrire depuis du code

La porte s'ouvre dans les deux sens : un seeder, un import ou votre propre code d'administration peuvent écrire directement, et la page reprendra la main au prochain chargement.

BusinessHours::create([
    'key' => 'shop-paris',
    'timezone' => 'Europe/Paris',
    'hours' => [
        'monday' => ['09:00-12:00', '14:00-18:00'],
        'tuesday' => ['09:00-18:00'],
    ],
    'exceptions' => [
        '12-25' => ['hours' => [], 'data' => 'Noël'],
    ],
]);

Les exceptions que la page ne sait pas représenter (périodes annuelles m-d to m-d, charges data structurées) sont conservées telles quelles d'une édition à l'autre : une définition écrite à la main survit au passage par l'interface.

Configuration

Chaque option se règle globalement dans le fichier config/filament-business-hours.php publié, ou par panel via l'API fluide du plugin. La valeur fluide l'emporte toujours.

FilamentBusinessHoursPlugin::make()
    // Navigation
    ->navigationLabel('Horaires')
    ->navigationIcon('heroicon-o-clock')
    ->navigationGroup('Établissement')
    ->navigationSort(10)
    ->navigationParentItem('Réglages')
    ->registerNavigation(true)
    ->slug('horaires')
    ->cluster(SettingsCluster::class)

    // Quelle ligne de la table business_hours ce panel gère
    ->record('shop-paris')
    // Votre propre modèle, tant qu'il étend celui du package
    ->model(App\Models\ShopHours::class)

    // Grille
    ->firstDayOfWeek('sunday')  // 'monday' … 'sunday'
    ->slotStep(30)              // aimantation, en minutes

    // Fuseaux horaires
    ->timezones(['Europe/Paris', 'Europe/Brussels'])
    ->defaultTimezone('Europe/Paris')

    // Autorisation
    ->canAccessUsing(fn (): bool => auth()->user()?->can('manageBusinessHours') ?? false);

Plusieurs jeux d'horaires

Chaque instance du plugin gère la ligne repérée par sa clé ->record(), à raison d'une page par panel. Un panel « Boutique Paris » et un panel « Boutique Lyon » exposent ainsi deux jeux d'horaires indépendants depuis la même table.

Contrôle d'accès

La page est accessible à toute personne pouvant accéder au panel. Restreignez-la avec une capacité de Gate :

'authorization' => [
    'gate' => 'manageBusinessHours',
],

Ou avec une closure, prioritaire sur le gate configuré :

FilamentBusinessHoursPlugin::make()
    ->canAccessUsing(fn (): bool => auth()->user()?->isAdmin() ?? false);

Dans les deux cas, cela pilote la méthode canAccess() de la page, donc la visibilité dans la navigation et l'autorisation de la route en même temps.

Référence des options

Clé de config Méthode fluide Défaut
navigation.register registerNavigation() true
navigation.label navigationLabel() « Horaires d'ouverture » traduit
navigation.icon navigationIcon() heroicon-o-clock
navigation.active_icon activeNavigationIcon() heroicon-s-clock
navigation.group navigationGroup() null
navigation.sort navigationSort() null
navigation.parent_item navigationParentItem() null
slug slug() business-hours
cluster cluster() null
record record() default
model model() BusinessHours::class
first_day_of_week firstDayOfWeek() monday
slot_step slotStep() 15 (borné entre 5 et 60)
timezones timezones() null (tous les identifiants IANA)
default_timezone defaultTimezone() null (fuseau de l'application)
authorization.gate canAccessUsing() null

Base de données

Une table, une ligne par jeu d'horaires.

Colonne Type Contenu
key string identifie le jeu (default, shop-paris, …), unique
timezone string / null identifiant IANA, null = fuseau de l'application
overflow bool drapeau « overflow » de spatie (plages après minuit)
hours json {"monday": ["09:00-12:00"], …}, format jour de spatie
exceptions json exceptions spatie (dates, m-d, périodes Y-m-d to Y-m-d)

Traductions

Le package est livré en français, anglais et espagnol, et suit la locale de votre application. Publiez les fichiers pour ajuster la formulation ou ajouter une langue :

php artisan vendor:publish --tag="filament-business-hours-translations"

Puis modifiez ou créez lang/vendor/filament-business-hours/{locale}/filament-business-hours.php.

À découvrir aussi

D'autres packages