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.
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.