rezzza/bitter-bundle
Symfony bundle that integrates the Bitter error-handling library, letting you map exceptions and errors to consistent HTTP responses through configurable handlers. Centralizes error management, keeps responses predictable, and makes failures easier to debug across your Symfony applications.
rezzza/bitter-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/bitter.php publish command, and php artisan will never see it. The bundle's only job is to read Symfony YAML and wire Bitter\Client into a Symfony DI container — work that Laravel cannot consume.
So the first decision is: do not install the bundle. Install the library it wraps.
composer require rezzza/bitter
If you want to understand the wiring contract before writing your own, read two files in the bundle repo:
Resources/config/services.xml (or .yml) — tells you exactly what constructor arguments Bitter\Client expects (a Redis client and, depending on version, a key namespace).composer.json — tells you which Symfony/PHP generation it targets, which is your fastest signal on whether the underlying library will even install cleanly on PHP 8.3.Then register it once, in AppServiceProvider:
use Bitter\Client;
public function register(): void
{
$this->app->singleton(Client::class, function ($app) {
// Bitter wants a raw phpredis \Redis instance, not the Laravel wrapper.
return new Client($app['redis']->connection('bitter')->client());
});
$this->app->alias(Client::class, 'bitter');
}
Note the dedicated bitter connection in config/database.php — point it at a separate Redis database index or, better, a separate logical Redis instance so you can flush analytics without touching cache or queues.
A thin facade (app/Facades/Bitter.php) is optional; the interface-first pattern below is the better habit.
First use case: daily active users. Bitter stores one bit per user per event per day, so "who logged in today" and "how many" are two calls instead of two million rows:
$bitter->track('login', now()->startOfDay(), [$user->id]); // set the bit
$dau = $bitter->count('login', today(), today()); // BITCOUNT
$wau = $bitter->count('login', today()->subDays(6), today()); // OR the range, then count
Verify the real signatures against vendor/rezzza/bitter/src/ — the API surface is small (roughly track, count, set, plus bitmap and/or/not primitives) and reading it end-to-end takes fifteen minutes. Then confirm the key layout empirically:
redis-cli --scan --pattern 'bitter:*'
redis-cli STRLEN bitter:login:2024-05-01 # bytes = highest user id / 8
That last command is the single most useful diagnostic you have, and it will immediately tell you whether your ID space is sane.
1. Wrap it behind an application-owned interface from day one.
Given the maintenance signal around this package (17 stars, zero dependents, an older Symfony generation), the cost of a two-file indirection is trivial compared to the cost of Bitter\Client leaking into forty call sites.
// app/Contracts/Analytics.php
interface Analytics
{
public function track(string $event, CarbonInterface $day, int|array $userId): void;
public function uniques(string $event, CarbonInterface $from, CarbonInterface $to): int;
public function overlap(array $events, CarbonInterface $from, CarbonInterface $to): int;
public function retained(string $event, CarbonInterface $cohortDay, int $dayN): int;
}
Bind Analytics to BitterAnalytics. When (not if) you swap the internals — a newer library, a direct Redis implementation, a warehouse backfill for parity — controllers and jobs are untouched.
2. Middleware for request-level activity.
The common day-to-day pattern is capturing an event for the authenticated user per request. Keep it off the request's critical path and strictly after the response is built:
public function handle($request, Closure $next)
{
$response = $next($request);
if ($user = $request->user()) {
try {
$this->analytics->track('request', today(), $user->id);
} catch (\Throwable $e) {
report($e); // analytics failure must never 500 a request
}
}
return $response;
}
For high-traffic endpoints, push to a queued job instead — one Redis round trip per request is cheap, but it is still a dependency on the hot path.
3. Funnels and retention via bitmap algebra.
This is where the pattern earns its keep. Bitter exposes the raw bitmap as an object with and(), or(), and a count, which maps directly onto funnel and cohort questions:
// Funnel step: signed up AND completed onboarding, in the same window
$signups = $bitter->set('signup', $from, $to);
$onboarded = $bitter->set('onboarding', $from, $to);
$converted = $signups->and($onboarded)->count();
// Retention: day-0 cohort that came back on day N
$cohort = $bitter->set('signup', $day0, $day0);
$returned = $cohort->and($bitter->set('login', $dayN, $dayN))->count();
Precompute these on a schedule and write the integers to a small table or cache. The bitmaps are the source of truth; the counts are what dashboards query, and recomputing BITCOUNT over a 30-day range on every page load is how you accidentally DDoS your own Redis.
4. Key namespacing is your schema — treat it as one.
Prefix by environment and tenant so a staging dump can never contaminate production or leak one customer's activity into another's:
// config/analytics.php
'prefix' => sprintf('%s:tenant:%d:bitter', app()->environment(), tenant()->id),
Document the resulting key format ({env}:tenant:{id}:bitter:{event}:{YYYY-MM-DD}) in the repo. Future maintainers will not find it in any docs, because there are none.
5. Scheduled rollups and TTL management.
Bitmaps never expire on their own. A nightly job should: (a) compute and persist the counts you actually display, and (b) EXPIRE raw daily keys to your retention horizon (e.g. 400 days for a year-over-year MAU comparison). Without (b) your Redis grows monotonically forever.
6. Testing.
Point the bitter connection at Redis DB 15 (or a predis/array fake behind your Analytics interface) in phpunit.xml. Assert on counts, not on keys:
$this->analytics->track('login', today(), 42);
$this->assertSame(1, $this->analytics->uniques('login', today(), today()));
Bitmap math is easy to get subtly wrong — write the retention tests against a hand-computed fixture of three or four users before you trust a single dashboard number.
User IDs must be small non-negative integers. Bitmaps are bit offsets. User id 10,000,000 means Redis allocates ~1.25 MB for a bitmap containing a single bit. If your users table uses UUIDs, you need an integer mapping table — and that mapping is now state you have to maintain, back up, and migrate. This is the number one surprise for teams coming from event tables, and it is the reason the pattern only pays off with a compact sequential ID space.
You cannot un-track. Setting a bit is idempotent; clearing it is not semantically meaningful for "did this user ever do X." Refunds, deletions, GDPR erasure requests, and "that was the wrong user" corrections do not fit the model. Plan a rebuild path (replay from an event log) before you have a compliance ticket.
Timezone: pick UTC and never deviate. A "day" is a key suffix. Users in Auckland and Los Angeles will land on different days if you let now() be local. Decide explicitly and encode it in the interface so callers cannot pass an ambiguous date.
BITCOUNT is O(N) over the bitmap length, not the number of set bits. A sparse, high-ID bitmap costs you scan time proportional to your largest user id even if only three bits are set. Use byte-range limits where the library supports it, and never run full-range counts inside a request.
Do not run this on a shared Redis with your cache. Analytics writes can be bursty and your key count grows daily; starving the session cache or the queue is a bad trade. Separate connection, ideally separate instance, definitely a separate Redis::connection().
Guard every call. Wrap writes in try/catch (or dispatch them), and give reads a fallback (last cached value, or null meaning "unknown") so an analytics outage degrades a dashboard rather than taking down checkout.
Debugging toolkit. STRLEN bitter:*:login:2024-05-01 gives you the allocated bytes and thus the max user id touched. BITCOUNT key gives the day's uniques. BITCOUNT key 0 124 lets you range-scan without pulling a megabyte over the wire. --scan --pattern shows the actual key layout, which is often not what you assumed.
The bundle's config tree is not an extension point for you. It exposes Symfony service parameters. The useful extension points are all in your code: the Analytics interface, the prefix generator, and the rollup job. Attempting to configure the bundle from Laravel will only teach you what a Symfony Extension class is, which is not what you need here.
Assume you own this indefinitely. There is no upgrade path, no security advisory feed, and no community to absorb a breaking change in a future PHP minor. Pin the version in composer.json, and vendor/fork the library if you go to production with it. The honest framing: rezzza/bitter is a reference implementation of the Redis-bitmap pattern, and the pattern is worth far more than the package. If you outgrow it — arbitrary segmentation, event-level forensics, multi-dimensional joins — you were going to end up in a warehouse anyway; keep a raw event log from day one so that migration is a backfill rather than a data loss.
How can I help you explore Laravel packages today?