mablae/time-window-bundle
A Symfony bundle for defining reusable time windows and checking whether the current time falls inside them, with support for recurring daily or weekly ranges, timezone-aware comparisons, and simple configuration for scheduling and availability rules.
First, the reality check that determines everything else. This is a Symfony bundle. In a Laravel app it will install via Composer and then never boot: there is no Kernel, no config/bundles.php, and Laravel does not load DependencyInjection/Extension classes. So "getting started" means one of two things — skim the source as reference prior art, or scaffold an internal equivalent. Do not put it in require and expect app('time_window') to resolve.
Step 1 — read the source, not the README (15 minutes). For a 0-star repo the code is the documentation. Open these in order:
src/DependencyInjection/Configuration.php — the ground truth for the window schema: start/end keys, timezone key, feature name, whether an enabled flag exists alongside the time bounds.isActive(string $feature): bool or isWithin(TimeWindow $w): bool. This is the ~50 lines you actually care about.*Extension::load) — how windows get compiled from config into objects, and whether results are cached.getContainerExtension() isn't copy-pasted from a template.Look specifically for: boundary inclusivity (>= vs >), timezone resolution (date_default_timezone_get() vs a configured zone vs UTC), and whether now is injected or hardcoded (hardcoded new \DateTime() means it is untestable and you should not trust its semantics).
Step 2 — scaffold the Laravel equivalent. One config file, one value object, one service:
// config/feature-flags.php
return [
'timezone' => env('FEATURE_FLAG_TZ', 'UTC'), // explicit, never app.timezone implicitly
'windows' => [
'black_friday_banner' => [
'start' => '2025-11-28T00:00:00',
'end' => '2025-12-02T00:00:00',
'timezone' => 'America/New_York',
'override' => null, // true|false|null — the kill switch
],
'beta_program' => [
'start' => '2025-01-01T00:00:00',
'end' => '2025-06-30T23:59:59',
],
],
];
final readonly class TimeWindow
{
public function __construct(
public CarbonImmutable $start,
public CarbonImmutable $end,
) {}
public function contains(CarbonImmutable $at): bool
{
return $at->greaterThanOrEqualTo($this->start)
&& $at->lessThan($this->end); // half-open: [start, end)
}
}
Step 3 — first use case, smallest possible. Pick exactly one: a scheduled promo banner, a beta gate on a single route, or a maintenance-mode flag. Wire it behind Feature::active('beta_program') with a facade binding FeatureFlagService as a singleton, and put the Blade check on one view. Ship it. The whole point of the exercise is to prove the pattern is boring; if your first use case needs recurring windows or per-user targeting, you have picked the wrong first use case.
Where to look first when something is "wrong": dump config('feature-flags.windows') and the resolved CarbonImmutable::now($tz) side by side. In practice, 90% of "the flag flipped at the wrong time" bugs are a timezone mismatch, not a logic bug.
Config-as-source-of-truth, keyed by flag name. Every window lives in config/feature-flags.php under a stable string key that matches the call site exactly. This gives you one grep-able place to audit every temporary flag and its expiry. Keep the keys flat (no nesting) so a config review diff shows flag-by-flag additions and removals.
Half-open intervals, documented once. Use [start, end) semantics everywhere: >= start && < end. This eliminates the classic off-by-one where two consecutive windows both claim the boundary instant, and it makes adjacent windows (e.g. seasonal pricing tiers) compose without gaps.
Override layered on top of the clock. Time drives the default; a manual override wins. This is the single most important pattern to copy from the bundle's design space, and the one most likely to be missing:
public function active(string $flag): bool
{
$cfg = config("feature-flags.windows.{$flag}");
if ($cfg === null) {
return false; // unknown flags fail closed
}
if (is_bool($cfg['override'] ?? null)) {
return $cfg['override'];
}
return $this->window($flag)->contains($this->now($cfg['timezone'] ?? config('feature-flags.timezone')));
}
The override can be an env var (FEATURE_BLACK_FRIDAY_OVERRIDE=false) so ops can flip it without a code change, or a Pennant flag, or a database row — pick one and be consistent.
Compose with Pennant rather than replacing it. If you already have or expect to need targeting (per-user, per-cohort, percentage), use Pennant for the flag backbone and make the time window a scope — a class implementing Pennant's scope contract that returns the window's state for every user identically. That keeps time-based and user-based flags behind one API, and when the window is deleted you delete a scope, not a bespoke service.
Gate at the route/middleware layer, not scattered through controllers. A EnsureFeatureActive middleware (->middleware('feature:beta_program')) keeps the check declarative and makes the full set of time-gated routes visible in routes/web.php. Reserve the facade call for views and conditional UI.
Memoize per request, not across requests. Bind the service as a singleton and cache resolved evaluations in an array property. Do not push evaluations into Redis or a shared cache without a config-version key: window definitions change on deploy, and a stale cache means a flag flips at the wrong moment. A date comparison is nanoseconds — the cache is for consistency, not performance.
Test with frozen time, always. The whole value of extracting this into a value object is testability:
public function test_window_boundaries(): void
{
$w = new TimeWindow(
CarbonImmutable::parse('2025-11-28 00:00:00', 'America/New_York'),
CarbonImmutable::parse('2025-12-02 00:00:00', 'America/New_York'),
);
$this->assertFalse($w->contains(CarbonImmutable::parse('2025-11-27 23:59:59', 'America/New_York')));
$this->assertTrue($w->contains(CarbonImmutable::parse('2025-11-28 00:00:00', 'America/New_York')));
$this->assertTrue($w->contains(CarbonImmutable::parse('2025-12-01 23:59:59', 'America/New_York')));
$this->assertFalse($w->contains(CarbonImmutable::parse('2025-12-02 00:00:00', 'America/New_York')));
}
Observability as a first-class pattern. Log at the moment a flag is evaluated outside its window (at debug or sampled info), and ship an Artisan command — php artisan flags:expiring --days=14 — that scans config and lists windows ending soon. Run it in CI or a weekly cron. This is the only defense against silent flag debt.
The bundle will never boot in Laravel — treat this as a hard fact, not a configuration challenge. No amount of service-provider shimming makes Symfony's Extension::load run. If you composer require it "just to try," you are adding a transitive dependency, an audit entry, and a composer.lock line for zero runtime value. Vendor the source into references/ if you want it visible, or follow the MIT terms and copy the evaluator outright with attribution.
Timezone resolution is the highest-risk detail. Three zones are in play: PHP's date_default_timezone_get(), config('app.timezone'), and any per-flag zone. Never rely on the first (server-dependent, drifts across containers), and be explicit that storing/parsing start and end in UTC but evaluating in a flag-local zone produces different results than doing both in the flag zone. Pick one invariant — parse in the flag's zone, evaluate at now($flagZone) — and enforce it in a single factory method so no call site can get it wrong.
DST will bite you, and the bundle almost certainly doesn't handle it. A window starting 2025-03-09 02:30:00 in America/New_York does not exist; Carbon will silently shift it (typically to 03:30). A window ending at 01:30 on a fall-back day occurs twice. Test both transitions explicitly with CarbonImmutable and decide whether you normalize to the next valid instant or reject the config at boot. A boot-time assertion that every configured window parses back to its declared wall-clock time catches most of these before production.
No kill switch means no incident response. A clock-driven flag that says "on" cannot be turned off by clock. Every time window must be paired with an override (env var, Pennant flag, or DB row) that ops can flip in seconds. If you cannot turn the feature off during an incident, the flag is a liability.
php artisan config:cache freezes windows until the next deploy. Changing a window requires config:cache in most setups, which in practice means a deploy. That is fine for a scheduled launch with a known date and totally wrong if the business expects to move the window ad hoc — in which case the window definition belongs in the database, not config. Decide this before the first flag ships.
Boundary and clock-skew edge cases at scale. With multiple app instances, every node must resolve the boundary identically. Ship NTP, pin the timezone explicitly in config, and never read date() or time() directly at a call site — inject the clock so tests can freeze it and so the source is auditable. Clock skew of a few milliseconds is irrelevant; a container with a 10-minute drift is not.
Silent expiry is the failure mode nobody notices. A window that has passed returns false forever; the feature code path becomes dead and stays in the repo indefinitely. Log when a configured window has ended, and treat removal of an expired window as part of the flag's definition of done.
Don't use Carbon::now() mutability in a shared service. If you inject a clock or a timezone, use CarbonImmutable throughout. A Carbon instance mutated somewhere down the call stack (e.g. ->addDay() from a caller) can silently move boundaries for subsequent evaluations in the same request.
Extension point if you ever are on Symfony. The evaluator service is the seam — decorate it (decorates: in services.yaml) rather than forking, so you can layer override and logging on top of the bundle's time logic. In Laravel the equivalent seam is an interface you bind in your own service provider — which is another reason to write the primitive yourself: you control the extension point from line one.
Maintenance posture: pin, fork, or copy — never float. With one maintainer, zero dependents, and no release history to read, a ^1.0 constraint has no meaningful upgrade contract. If you take any code from this repo, record the commit SHA in a comment next to the copied class so future readers can diff against upstream.
How can I help you explore Laravel packages today?