Looking to hire Laravel developers? Try LaraJobs

laravel-bit-masks maintained by zuko

Description
Bitmask toolkit for Laravel — flag class generator, Eloquent integration, fluent query scopes and bit-level helpers.
Author
Last update
2026/07/20 10:55 (dev-master)
License
Links
Downloads
11

Comments
comments powered by Disqus

zuko/laravel-bit-masks

Also have a Tiếng Việt version of this README.

Bitmask toolkit for Laravel — flag class generator, Eloquent integration, fluent query scopes and bit-level helpers.

Store dozens of boolean flags in a single integer column (e.g. "which networks is this email listed in?" across 500M+ rows), and work with them through a clean, typed API instead of hand-rolled bitwise SQL.

  • ⚙️ Generatorphp artisan make:bitmask scaffolds int-backed flag enums (or constants classes) with power-of-two values.
  • 🧩 Eloquent trait — declare your mask columns once; casting, helpers and query scopes are wired automatically.
  • 🔍 Fluent scopeswhereMaskHas, whereMaskHasAny, whereMaskMissing, whereMaskEquals (+ orWhere* variants).
  • 🛠 Value object — immutable BitMask with has / add / remove / toggle / intersect / diff / names ....
  • 📦 Collections — the same filters, in-memory, on Eloquent collections or plain collections.
  • 🧵 Wide masks — a single logical mask spanning several BIGINT columns, for more than 63 flags (e.g. 126 across two columns) without hand-juggling networks_1 / networks_2.
  • 🗃 Junction (pivot) masks — the same flag API backed by a (owner, flag_id) table instead of columns, for large or dynamic flag sets — with EXISTS-based scopes and a reverse-lookup index.

Requirements

  • PHP 8.2+
  • Laravel 11, 12 or 13

Installation

composer require zuko/laravel-bit-masks

The service provider is auto-discovered.

Quick start

1. Generate a flag enum:

php artisan make:bitmask Network --flags="gmail,yahoo,outlook,hotmail"
// app/BitMasks/Network.php
enum Network: int
{
    use BitMaskFlags;

    case Gmail = 1 << 0;
    case Yahoo = 1 << 1;
    case Outlook = 1 << 2;
    case Hotmail = 1 << 3;
}

2. Add the column (migration):

Schema::create('subscribers', function (Blueprint $table) {
    $table->id();
    $table->string('email')->unique();
    $table->bitMask('networks'); // unsignedBigInteger, default 0
});

3. Declare it on the model:

use Zuko\BitMasks\Concerns\HasBitMasks;

class Subscriber extends Model
{
    use HasBitMasks;

    protected $bitMasks = [
        'networks' => Network::class, // bound to a flag enum
        // 'toggles',                 // or a plain mask column
    ];
}

4. Done — the whole API is live:

$s = Subscriber::first();

$s->networks;                                  // BitMask instance
$s->networks->names();                         // ['Gmail', 'Yahoo']
$s->hasMask('networks', Network::Gmail);       // true
$s->addMask('networks', Network::Outlook)->save();

Subscriber::whereMaskHas('networks', Network::Gmail)->count();
Subscriber::all()->whereMaskHasAny('networks', [Network::Yahoo, Network::Outlook]);

The BitMask value object

Immutable — every mutation returns a new instance. Anywhere a flag is accepted, you may pass an int, an int-backed enum case, another BitMask, or an (nested) iterable of those.

use Zuko\BitMasks\BitMask;

$mask = BitMask::from([Network::Gmail, Network::Yahoo]); // enum binding auto-detected
$mask = BitMask::from(0b0101, Network::class);           // explicit binding
$mask = BitMask::none();                                 // 0
$mask = BitMask::all(Network::class);                    // every case set
$mask = bitmask(5, Network::class);                      // global helper
Method Description
value(): int Raw integer value
isEmpty(): bool No bit set
has(...$flags): bool All given flags present
hasAny(...$flags): bool At least one flag present
hasNone(...$flags): bool None of the flags present
equals($flags): bool Exact match
add(...$flags): self Set flags (OR)
remove(...$flags): self Unset flags (AND NOT)
toggle(...$flags): self Flip flags (XOR)
clear(): self Empty mask
intersect($flags): self Bits in both (AND)
union($flags): self Bits in either (OR)
diff($flags): self Bits here but not there
bits(): array Set bit positions, e.g. [1, 3]
values(): array Power-of-two components, e.g. [2, 8]
flags(): array Enum cases (when bound) or values
names(): array Enum case names (requires bound enum)
count(): int Number of set bits (Countable)
toBits(): string Binary string, e.g. "1010"
enum() / withEnum($class) Read / bind the flag enum

BitMask::resolve(mixed): int is the underlying normalizer — use it whenever you need a plain integer.

Serialization: json_encode($mask) and (string) $mask both yield the integer value.

Flag enums — BitMaskFlags

Generated enums ship with this trait; any int-backed enum can adopt it:

Network::mask(Network::Gmail, Network::Yahoo); // BitMask(3), bound to Network
Network::none();                               // empty BitMask
Network::all();                                // every case set
Network::fromMask(5);                          // [Network::Gmail, Network::Outlook]
Network::Gmail->in($subscriber->networks);     // membership check
Network::Yahoo->notIn(0b0101);                 // true

Eloquent integration — HasBitMasks

Declare mask columns via $bitMasks (plain names, or column => FlagEnum::class). The trait then:

  1. Casts each column to a BitMask (via AsBitMask), unless you declared your own cast.
  2. Adds instance helpers (mutations are in-memory; chain ->save() to persist):
$model->bitMask('networks');                    // BitMask (never null)
$model->hasMask('networks', Network::Gmail);    // all flags present?
$model->hasAnyMask('networks', ...$flags);      // any flag present?
$model->missingMask('networks', ...$flags);     // none present?
$model->addMask('networks', ...$flags);         // set flags
$model->removeMask('networks', ...$flags);      // unset flags
$model->toggleMask('networks', ...$flags);      // flip flags
$model->setMask('networks', $flags);            // replace entirely
$model->clearMask('networks');                  // reset to 0
  1. Adds query scopes (portable across MySQL / PostgreSQL / SQLite):
Scope SQL Matches rows…
whereMaskHas($col, $flags) (col & m) = m with all flags
whereMaskHasAny($col, $flags) (col & m) != 0 with any flag
whereMaskMissing($col, $flags) (col & m) = 0 with none of the flags
whereMaskEquals($col, $flags) col = m exact mask

Each has an orWhere* twin, and accepts an optional $boolean argument for manual grouping:

Subscriber::whereMaskHas('networks', Network::Gmail)
    ->orWhereMaskHas('networks', [Network::Yahoo, Network::Outlook])
    ->get();

You can also assign masks naturally — the cast resolves anything flag-ish:

$subscriber->networks = [Network::Gmail, Network::Hotmail];
$subscriber->save(); // stored as 0b1001

Setting attributes without the trait

The cast is usable standalone:

protected $casts = [
    'networks' => AsBitMask::class,                  // plain
    'networks' => AsBitMask::using(Network::class),  // enum-bound
];

Collections

The scopes have in-memory twins, registered as Collection macros — they work on Eloquent collections of trait-using models and on plain collections of arrays/objects:

$subscribers->whereMaskHas('networks', Network::Gmail);
$subscribers->whereMaskHasAny('networks', [Network::Yahoo]);
$subscribers->whereMaskMissing('networks', Network::Outlook);
$subscribers->whereMaskEquals('networks', 0);

collect([['mask' => 0b11], ['mask' => 0b01]])->whereMaskHas('mask', 0b10);

Generator reference

php artisan make:bitmask {name}
    {--flags=}        # comma-separated flag names: --flags="gmail,yahoo mail,out-look"
    {--from-file=}    # file with one flag name per line
    {--type=enum}     # "enum" (default) or "constants"
    {--namespace=}    # default: App\BitMasks
    {--path=}         # default: app/BitMasks
    {--start=0}       # bit position of the first flag
    {--force}         # overwrite existing file

Names are normalized into identifiers (yahoo mailYahooMail / YAHOO_MAIL); duplicates and >63-bit overflows are rejected. --start lets you append new flags to an existing sequence without renumbering (generate a second class, or regenerate with the full list).

--type=constants produces a plain class for codebases that prefer constants:

final class Network
{
    public const GMAIL = 1 << 0;
    public const YAHOO = 1 << 1;
}

Schema helper

$table->bitMask('networks');            // = $table->unsignedBigInteger('networks')->default(0)
$table->wideBitMask('networks', 2);     // networks_1, networks_2 — two BIGINTs, default 0 (see Wide masks)
$table->flagPivot('email', 'network_id'); // junction table: flag_id column + composite PK + reverse index (see Junction masks)

Wide masks — more than 63 flags

One BIGINT holds 63 usable flags. When you need more (this package was built for "which of 100+ networks is this email listed in?"), a wide mask presents a single logical mask backed by several BIGINT columns — 63 flags each — so two columns give you 126 flags, three give 189, and so on.

The distinction is in how flags are addressed. A single-column flag enum uses the bit value (1 << n); a wide-mask flag enum uses the global index (0, 1, … 125), because a mask past bit 62 can't fit in one PHP integer. Index n routes to column n / 63, bit n % 63.

1. Flag enum backed by global index:

enum Network: int
{
    case Gmail = 0;    // column 1, bit 0
    case Yahoo = 1;
    // …
    case Proton = 63;  // column 2, bit 0  (the 64th flag)
    case Icloud = 125; // column 2, bit 62 (the 126th flag)
}

2. Columns — the wideBitMask blueprint macro creates networks_1, networks_2:

Schema::create('emails', function (Blueprint $table) {
    $table->string('email')->primary();
    $table->wideBitMask('networks', 2); // networks_1, networks_2 (BIGINT default 0)
});

3. Declare it on the model with an array value (vs. a bare enum for a single column):

class Email extends Model
{
    use HasBitMasks;

    protected $bitMasks = [
        // 'columns' may be a count (derives networks_1..N) or an explicit list.
        'networks' => ['columns' => 2, 'enum' => Network::class],
    ];
}

4. Same API — the value is now a WideBitMask:

$email->networks = [Network::Gmail, Network::Proton]; // fans out to networks_1 & networks_2
$email->networks;                                     // WideBitMask instance
$email->networks->names();                            // ['Gmail', 'Proton']
$email->hasMask('networks', Network::Icloud);         // false
$email->addMask('networks', Network::Icloud)->save();

Email::whereMaskHas('networks', Network::Icloud)->get();          // matched in the right column
Email::whereMaskHasAny('networks', [Network::Gmail, Network::Proton])->get(); // OR across columns

The scopes emit per-column bitwise predicates automatically (AND across columns for has / missing / equals, OR for hasAny), all wrapped in a single grouped clause so they compose with orWhere* and your other conditions.

WideBitMask mirrors BitMask (has / hasAny / hasNone / equals / add / remove / toggle / clear / bits / flags / names / count); ->columns() returns the raw per-column integers, and bits() returns global indices.

Junction (pivot) masks — large or dynamic flag sets

When flags are too many even for a wide mask, or you want each membership as its own row (easy bulk load, per-flag reverse lookups, flag_ids that aren't contiguous bit positions), store them in a thin junction table(owner_key, flag_id), one row per set flag — instead of mask columns. The flag_id is the int-backed enum case's value (any non-negative integer, not a bit position), so the flag count is effectively unbounded.

It's the same declaration and the same API — only the storage differs.

1. Flag enum (values are arbitrary flag ids):

enum Network: int
{
    case Gmail = 1;
    case Proton = 100;
    case Icloud = 250;
}

2. Junction table — the flagPivot macro adds the flag_id column, the composite primary key, and a reverse index (flag_id, owner) for "which owners carry flag X?":

Schema::create('email_networks', function (Blueprint $table) {
    $table->string('email');                    // owner column (its type is yours)
    $table->flagPivot('email', 'network_id');   // + network_id, PK & reverse index
});

3. Declare it on the model with a pivot key. foreignPivotKey, flagKey and ownerKey are optional — they default to the model's foreign key, flag_id, and the model's primary key:

class Email extends Model
{
    use HasBitMasks;

    protected $bitMasks = [
        'networks' => [
            'pivot' => 'email_networks',
            'enum'  => Network::class,
            'foreignPivotKey' => 'email',   // owner column in the junction table
            'flagKey'         => 'network_id',
            'ownerKey'        => 'email',    // local key it references
        ],
    ];
}

4. Same API — the value is now a FlagSet:

$email->networks = [Network::Gmail, Network::Proton]; // buffered…
$email->save();                                       // …flushed to the junction table

$email->networks;                              // FlagSet instance
$email->networks->names();                     // ['Gmail', 'Proton']
$email->hasMask('networks', Network::Icloud);  // false
$email->addMask('networks', Network::Icloud)->save();

Email::whereMaskHas('networks', Network::Gmail)->get();
Email::whereMaskHasAny('networks', [Network::Proton, Network::Icloud])->get();

Notes:

  • Mutations flush on ->save() (same contract as the column strategies) — the pivot rows are reconciled to the buffered set. A flush needs the owner key, so save the model at least once. Deleting the model purges its junction rows.
  • Scopes emit correlated EXISTS / NOT EXISTS subqueries: whereMaskHas is an EXISTS per flag, whereMaskHasAny an EXISTS … flag_id IN (…), whereMaskMissing its NOT EXISTS, and whereMaskEquals an exact match. All four (and their orWhere* twins) compose with your other conditions.
  • FlagSet mirrors BitMask (has / hasAny / hasNone / equals / add / remove / toggle / clear / flags / names / count); ->ids() returns the raw flag ids.

Choosing a storage strategy

Flags Strategy Declaration
≤ 63 single column 'networks' => Network::class
64 – a few hundred wide mask 'networks' => ['columns' => N, 'enum' => …]
many / dynamic / bulk-loaded junction table 'networks' => ['pivot' => 'table', 'enum' => …]

Bitmask columns keep every flag in the row (one-row point lookups, no joins); junction tables trade that for unbounded, individually-indexable flags.

Limits & querying at scale

  • One column holds 63 usable flags (bit 63 is the sign bit on signed BIGINT engines). The generator enforces this; need more? Reach for a wide mask (several BIGINT columns behind one logical name), or — beyond a few hundred dynamic flags — a (flag_id, row_id) junction table with a composite index usually queries better.
  • whereMaskHas-style predicates can't use a plain B-tree index; on huge tables (hundreds of millions of rows) PostgreSQL partial indexes keep hot-flag queries fast:
CREATE INDEX idx_subscribers_net_5 ON subscribers (email) WHERE (networks & 32) != 0;
  • Point lookups (WHERE email = ?) are unaffected — the mask rides along in the row.

Testing

composer install
composer test

License

MIT © Zuko