diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8dc27de
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: true
+ matrix:
+ php: [8.2, 8.3, 8.4]
+ laravel: [11.*, 12.*]
+ exclude:
+ - php: 8.2
+ laravel: 12.*
+
+ name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, sqlite3
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts:${{ matrix.laravel }}"
+
+ - name: Check code style
+ run: vendor/bin/pint --test
+
+ - name: Run PHPStan
+ run: vendor/bin/phpstan
+
+ - name: Run tests
+ run: vendor/bin/pest --colors=always
diff --git a/.gitignore b/.gitignore
index d5673e3..7d2a82b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,30 +1,13 @@
+/.phpunit.result.cache
+/.phpunit.cache
+/.phpstan.cache
+/.php-cs-fixer.cache
+/.php-cs-fixer.php
+/composer.lock
+/phpunit.xml
/vendor/
-node_modules/
-npm-debug.log
-yarn-error.log
-
-# Laravel 4 specific
-bootstrap/compiled.php
-app/storage/
-
-# Laravel 5 & Lumen specific
-public/storage
-public/hot
-
-# Laravel 5 & Lumen specific with changed public path
-public_html/storage
-public_html/hot
-
-storage/*.key
-.env
-Homestead.yaml
-Homestead.json
-/.vagrant
-.phpunit.result.cache
-
-/public/build
-/storage/pail
-.env.backup
-.env.production
-.phpactor.json
-auth.json
+*.swp
+*.swo
+/.phpactor.json
+/.idea
+/.claude/settings.local.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8f40f7f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,229 @@
+
+
+
+
+# Transistor
+
+Laravel integration for [gksh/bitmask](https://github.com/gksh/bitmask) - providing Eloquent casting, query scopes, validation, Blade directives, and migration macros for working with bitmask flags.
+
+[](https://packagist.org/packages/gksh/transistor)
+[](https://packagist.org/packages/gksh/transistor)
+[](https://packagist.org/packages/gksh/transistor)
+
+## Installation
+
+```bash
+composer require gksh/transistor
+```
+
+The service provider is auto-discovered by Laravel.
+
+## Quick Start
+
+```php
+// 1. Generate a bitmask enum
+php artisan make:bitmask-flags Permission
+
+// 2. Add migration macro
+Schema::create('users', function (Blueprint $table) {
+ $table->id();
+ $table->bitmask('permissions');
+});
+
+// 3. Configure your model
+use Gksh\Transistor\Casts\BitmaskCast;
+use Gksh\Transistor\Concerns\HasBitmaskScopes;
+
+class User extends Model
+{
+ use HasBitmaskScopes;
+
+ protected function casts(): array
+ {
+ return [
+ 'permissions' => BitmaskCast::class,
+ ];
+ }
+}
+
+// 4. Use it
+$user->permissions = $user->permissions
+ ->set(Permission::Read)
+ ->set(Permission::Write);
+
+User::whereHasBitmaskFlag('permissions', Permission::Admin)->get();
+```
+
+## Artisan Commands
+
+### make:bitmask-flags
+
+Generate a bitmask flags enum interactively:
+
+```bash
+php artisan make:bitmask-flags
+php artisan make:bitmask-flags Permission
+```
+
+Creates an enum in `app/Enums` with bit-shifted values:
+
+```php
+namespace App\Enums;
+
+enum Permission: int
+{
+ case Read = 1 << 0; // 1
+ case Write = 1 << 1; // 2
+ case Delete = 1 << 2; // 4
+ case Admin = 1 << 3; // 8
+}
+```
+
+### bitmask:inspect
+
+Display bitmask flags as a lookup table:
+
+```bash
+php artisan bitmask:inspect "App\Enums\Permission"
+php artisan bitmask:inspect Permission 13
+```
+
+```
++--------+---------+-----------------+
+| Case | Decimal | Binary |
++--------+---------+-----------------+
+| Read | 1 | 0 0 0 0 0 0 0 1 |
+| Write | 2 | 0 0 0 0 0 0 1 0 |
+| Delete | 4 | 0 0 0 0 0 1 0 0 |
+| Admin | 8 | 0 0 0 0 1 0 0 0 |
++--------+---------+-----------------+
+| Value | 13 | 0 0 0 0 1 1 0 1 |
++--------+---------+-----------------+
+```
+
+When a value is provided, active bits are highlighted in green. Heavily inspired by https://laracasts.com/series/lukes-larabits/episodes/18.
+
+## Eloquent Cast
+
+Cast database integers to `Bitmask` objects:
+
+```php
+use Gksh\Transistor\Casts\BitmaskCast;
+
+protected function casts(): array
+{
+ return [
+ 'permissions' => BitmaskCast::class, // 32-bit (default)
+ 'settings' => BitmaskCast::class.':tiny', // 8-bit (0-255)
+ 'features' => BitmaskCast::class.':small', // 16-bit (0-65,535)
+ 'options' => BitmaskCast::class.':medium', // 24-bit (0-16,777,215)
+ ];
+}
+```
+
+The cast returns a `Gksh\Bitmask\Bitmask` object with methods like `set()`, `unset()`, `toggle()`, `has()`, and `value()`.
+
+## Query Scopes
+
+Add the `HasBitmaskScopes` trait to your model:
+
+```php
+use Gksh\Transistor\Concerns\HasBitmaskScopes;
+
+class User extends Model
+{
+ use HasBitmaskScopes;
+}
+```
+
+### Available Scopes
+
+```php
+// Filter where flag IS set
+User::whereHasBitmaskFlag('permissions', Permission::Read)->get();
+
+// Filter where ALL flags are set
+User::whereHasAllBitmaskFlags('permissions', [Permission::Read, Permission::Write])->get();
+
+// Filter where ANY flag is set
+User::whereHasAnyBitmaskFlag('permissions', [Permission::Write, Permission::Admin])->get();
+
+// Filter where flag is NOT set
+User::whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)->get();
+
+// Filter where NONE of the flags are set
+User::whereDoesntHaveAnyBitmaskFlag('permissions', [Permission::Read, Permission::Write])->get();
+```
+
+Scopes can be chained:
+
+```php
+User::whereHasBitmaskFlag('permissions', Permission::Read)
+ ->whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)
+ ->get();
+```
+
+## Migration Macros
+
+Create bitmask columns with the appropriate integer size:
+
+```php
+Schema::create('users', function (Blueprint $table) {
+ $table->id();
+ $table->bitmask('permissions'); // unsigned integer (32-bit)
+ $table->tinyBitmask('settings'); // unsigned tinyInteger (8-bit)
+ $table->smallBitmask('features'); // unsigned smallInteger (16-bit)
+ $table->mediumBitmask('options'); // unsigned mediumInteger (24-bit)
+});
+```
+
+All macros set a default value of `0`.
+
+## Validation
+
+### As a Rule Object
+
+```php
+use Gksh\Transistor\Rules\ValidBitmask;
+
+$validated = $request->validate([
+ 'permissions' => ['required', new ValidBitmask()],
+ 'role_flags' => ['required', new ValidBitmask(Permission::class)],
+]);
+```
+
+### As a String Rule
+
+```php
+$validated = $request->validate([
+ 'permissions' => 'required|bitmask',
+ 'role_flags' => 'required|bitmask:App\Enums\Permission',
+]);
+```
+
+When an enum class is provided, the rule ensures the value doesn't exceed the maximum possible combination of all flags.
+
+## Blade Directives
+
+```blade
+@hasBitmaskFlag($user->permissions, Permission::Admin)
+ Administrator
+@endif
+
+@hasAnyBitmaskFlag($user->permissions, [Permission::Write, Permission::Admin])
+
+@endif
+
+@hasAllBitmaskFlags($user->permissions, [Permission::Read, Permission::Write])
+ Full Access
+@endif
+```
+
+## Requirements
+
+- PHP 8.2+
+- Laravel 11.x or 12.x
+
+## License
+
+MIT
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1337493
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,62 @@
+{
+ "name": "gksh/transistor",
+ "description": "Laravel integration for gksh/bitmask",
+ "keywords": ["laravel", "bitmask", "bitwise", "flags", "eloquent", "cast"],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Gustavo Karkow",
+ "email": "karkowg@gmail.com"
+ }
+ ],
+ "require": {
+ "php": "^8.2",
+ "gksh/bitmask": "^1.0",
+ "illuminate/contracts": "^11.0|^12.0",
+ "illuminate/database": "^11.0|^12.0",
+ "illuminate/support": "^11.0|^12.0",
+ "laravel/prompts": "^0.1|^0.2|^0.3"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.18",
+ "orchestra/testbench": "^9.0|^10.0",
+ "pestphp/pest": "^3.0|^4.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Gksh\\Transistor\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Gksh\\Transistor\\Tests\\": "tests/"
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Gksh\\Transistor\\TransistorServiceProvider"
+ ]
+ }
+ },
+ "scripts": {
+ "lint": "pint --test",
+ "lint:fix": "pint",
+ "test": "pest",
+ "analyse": "phpstan",
+ "ci": [
+ "@lint",
+ "@analyse",
+ "@test"
+ ]
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..5547d69
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,10 @@
+includes:
+ - ./vendor/phpstan/phpstan/conf/bleedingEdge.neon
+
+parameters:
+ level: max
+ paths:
+ - src
+ tmpDir: .phpstan.cache
+ ignoreErrors:
+ - '#Trait .+ is used zero times and is not analysed#'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..aa23c17
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ ./tests
+
+
+
+
+ ./src
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..a74e070
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,7 @@
+{
+ "preset": "laravel",
+ "rules": {
+ "declare_strict_types": true,
+ "final_class": true
+ }
+}
diff --git a/src/Casts/BitmaskCast.php b/src/Casts/BitmaskCast.php
new file mode 100644
index 0000000..739f308
--- /dev/null
+++ b/src/Casts/BitmaskCast.php
@@ -0,0 +1,53 @@
+
+ */
+final class BitmaskCast implements CastsAttributes
+{
+ /**
+ * @param 'tiny'|'small'|'medium'|'default' $size
+ */
+ public function __construct(
+ protected string $size = 'default',
+ ) {}
+
+ /**
+ * @param array $attributes
+ */
+ public function get(Model $model, string $key, mixed $value, array $attributes): ?Bitmask
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $intValue = is_numeric($value) ? (int) $value : 0;
+
+ return match ($this->size) {
+ 'tiny' => Bitmask::tiny($intValue),
+ 'small' => Bitmask::small($intValue),
+ 'medium' => Bitmask::medium($intValue),
+ default => Bitmask::make($intValue),
+ };
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public function set(Model $model, string $key, mixed $value, array $attributes): ?int
+ {
+ return match (true) {
+ $value === null => null,
+ $value instanceof Bitmask => $value->value(),
+ default => (int) $value,
+ };
+ }
+}
diff --git a/src/Commands/InspectBitmaskCommand.php b/src/Commands/InspectBitmaskCommand.php
new file mode 100644
index 0000000..ad28e54
--- /dev/null
+++ b/src/Commands/InspectBitmaskCommand.php
@@ -0,0 +1,160 @@
+resolveEnumClass();
+
+ if ($enumClass === null) {
+ return self::FAILURE;
+ }
+
+ if (! $this->validateEnum($enumClass)) {
+ return self::FAILURE;
+ }
+
+ $value = $this->resolveValue();
+
+ if ($value === false) {
+ return self::FAILURE;
+ }
+
+ $this->displayTable($enumClass, $value);
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * @return class-string|null
+ */
+ private function resolveEnumClass(): ?string
+ {
+ /** @var string $input */
+ $input = $this->argument('enum');
+
+ // Try the input as-is first (FQCN)
+ if (enum_exists($input)) {
+ /** @var class-string $input */
+ return $input;
+ }
+
+ // Try with App\Enums namespace
+ $withNamespace = 'App\\Enums\\'.$input;
+ if (enum_exists($withNamespace)) {
+ /** @var class-string $withNamespace */
+ return $withNamespace;
+ }
+
+ $this->error("Enum class '{$input}' not found.");
+
+ return null;
+ }
+
+ /**
+ * @param class-string $enumClass
+ */
+ private function validateEnum(string $enumClass): bool
+ {
+ $reflection = new ReflectionEnum($enumClass);
+
+ if ($reflection->isBacked()) {
+ /** @var \ReflectionNamedType $backingType */
+ $backingType = $reflection->getBackingType();
+
+ if ($backingType->getName() !== 'int') {
+ $this->error('Only integer-backed enums are supported.');
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function resolveValue(): int|null|false
+ {
+ $input = $this->argument('value');
+
+ if ($input === null) {
+ return null;
+ }
+
+ if (! is_numeric($input) || (int) $input < 0) {
+ $this->error('Value must be a non-negative integer.');
+
+ return false;
+ }
+
+ return (int) $input;
+ }
+
+ /**
+ * @param class-string $enumClass
+ */
+ private function displayTable(string $enumClass, ?int $value): void
+ {
+ /** @var array $cases */
+ $cases = $enumClass::cases();
+
+ $caseValues = array_map(maskValue(...), $cases);
+ $maxValue = max($value ?? 0, ...$caseValues);
+ $bitWidth = max(8, (int) ceil(log($maxValue + 1, 2)));
+
+ $rows = [];
+
+ foreach ($cases as $case) {
+ $caseValue = maskValue($case);
+ $rows[] = [
+ $case->name,
+ $caseValue,
+ $this->formatBinaryColored($caseValue, $bitWidth),
+ ];
+ }
+
+ if ($value !== null) {
+ $rows[] = new \Symfony\Component\Console\Helper\TableSeparator;
+ $rows[] = [
+ 'Value',
+ $value,
+ $this->formatBinaryColored($value, $bitWidth),
+ ];
+ }
+
+ $this->table(['Case', 'Decimal', 'Binary'], $rows);
+ }
+
+ private function formatBinaryColored(int $value, int $width): string
+ {
+ $binary = str_pad(decbin($value), $width, '0', STR_PAD_LEFT);
+ $bits = str_split($binary);
+
+ $colored = array_map(
+ fn (string $bit): string => $bit === '1'
+ ? "{$bit}>"
+ : "{$bit}>",
+ $bits,
+ );
+
+ return implode(' ', $colored);
+ }
+}
diff --git a/src/Commands/MakeBitmaskFlagsCommand.php b/src/Commands/MakeBitmaskFlagsCommand.php
new file mode 100644
index 0000000..78b84a9
--- /dev/null
+++ b/src/Commands/MakeBitmaskFlagsCommand.php
@@ -0,0 +1,110 @@
+ */
+ protected array $flags = [];
+
+ protected function getStub(): string
+ {
+ return __DIR__.'/../stubs/bitmask-flags.stub';
+ }
+
+ protected function getDefaultNamespace($rootNamespace): string
+ {
+ return $rootNamespace.'\\Enums';
+ }
+
+ /**
+ * @return array>
+ */
+ protected function promptForMissingArgumentsUsing(): array
+ {
+ return [
+ 'name' => ['What should the enum be named?', 'E.g. Permission'],
+ ];
+ }
+
+ protected function interact(InputInterface $input, OutputInterface $output): void
+ {
+ $this->collectFlags();
+ }
+
+ public function handle(): ?bool
+ {
+ if (empty($this->flags)) {
+ $this->error('At least one flag is required.');
+
+ return false;
+ }
+
+ return parent::handle();
+ }
+
+ protected function collectFlags(): void
+ {
+ $this->info('Enter flag names (empty to finish):');
+
+ for ($index = 1; ; $index++) {
+ $flag = text(
+ label: "Flag {$index}",
+ placeholder: $index === 1 ? 'e.g. Read' : '',
+ validate: fn (string $value): ?string => $value === '' ? null : $this->validateFlagName($value),
+ );
+
+ if ($flag === '') {
+ return;
+ }
+
+ $this->flags[] = Str::studly($flag);
+ }
+ }
+
+ protected function buildClass($name): string
+ {
+ $stub = parent::buildClass($name);
+
+ return str_replace('{{ cases }}', $this->buildCases(), $stub);
+ }
+
+ protected function buildCases(): string
+ {
+ $cases = array_map(
+ fn (string $flag, int $index): string => " case {$flag} = 1 << {$index};",
+ $this->flags,
+ array_keys($this->flags),
+ );
+
+ return implode("\n", $cases);
+ }
+
+ private function validateFlagName(string $value): ?string
+ {
+ $studly = Str::studly($value);
+
+ if (in_array($studly, $this->flags, true)) {
+ return "The flag '{$studly}' already exists.";
+ }
+
+ return null;
+ }
+}
diff --git a/src/Concerns/HasBitmaskScopes.php b/src/Concerns/HasBitmaskScopes.php
new file mode 100644
index 0000000..100ac68
--- /dev/null
+++ b/src/Concerns/HasBitmaskScopes.php
@@ -0,0 +1,97 @@
+ $query
+ * @return Builder
+ */
+ public function scopeWhereHasBitmaskFlag(Builder $query, string $column, int|BackedEnum|UnitEnum $flag): Builder
+ {
+ $value = maskValue($flag);
+
+ return $query->whereRaw("{$column} & ? = ?", [$value, $value]);
+ }
+
+ /**
+ * Scope to filter records where the bitmask column has ALL specified flags set.
+ *
+ * @param Builder $query
+ * @param array $flags
+ * @return Builder
+ */
+ public function scopeWhereHasAllBitmaskFlags(Builder $query, string $column, array $flags): Builder
+ {
+ $value = $this->combinedMaskValue($flags);
+
+ return $query->whereRaw("{$column} & ? = ?", [$value, $value]);
+ }
+
+ /**
+ * Scope to filter records where the bitmask column has ANY of the specified flags set.
+ *
+ * @param Builder $query
+ * @param array $flags
+ * @return Builder
+ */
+ public function scopeWhereHasAnyBitmaskFlag(Builder $query, string $column, array $flags): Builder
+ {
+ $value = $this->combinedMaskValue($flags);
+
+ return $query->whereRaw("{$column} & ? != 0", [$value]);
+ }
+
+ /**
+ * Scope to filter records where the bitmask column does NOT have a specific flag set.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWhereDoesntHaveBitmaskFlag(Builder $query, string $column, int|BackedEnum|UnitEnum $flag): Builder
+ {
+ $value = maskValue($flag);
+
+ return $query->whereRaw("{$column} & ? != ?", [$value, $value]);
+ }
+
+ /**
+ * Scope to filter records where the bitmask column does NOT have ANY of the specified flags set.
+ *
+ * @param Builder $query
+ * @param array $flags
+ * @return Builder
+ */
+ public function scopeWhereDoesntHaveAnyBitmaskFlag(Builder $query, string $column, array $flags): Builder
+ {
+ $value = $this->combinedMaskValue($flags);
+
+ return $query->whereRaw("{$column} & ? = 0", [$value]);
+ }
+
+ /**
+ * @param array $flags
+ */
+ private function combinedMaskValue(array $flags): int
+ {
+ return array_reduce(
+ array_map(maskValue(...), $flags),
+ fn (int $carry, int $value): int => $carry | $value,
+ 0
+ );
+ }
+}
diff --git a/src/Rules/ValidBitmask.php b/src/Rules/ValidBitmask.php
new file mode 100644
index 0000000..38dfe17
--- /dev/null
+++ b/src/Rules/ValidBitmask.php
@@ -0,0 +1,66 @@
+|null $enumClass
+ */
+ public function __construct(
+ private readonly ?string $enumClass = null,
+ ) {}
+
+ public function validate(string $attribute, mixed $value, Closure $fail): void
+ {
+ if (! $this->passes($attribute, $value)) {
+ $fail('The :attribute must be a valid bitmask value.');
+ }
+ }
+
+ public function passes(string $attribute, mixed $value): bool
+ {
+ if (! is_int($value) && ! is_numeric($value)) {
+ return false;
+ }
+
+ $intValue = (int) $value;
+
+ if ($intValue < 0) {
+ return false;
+ }
+
+ if ($this->enumClass === null) {
+ return true;
+ }
+
+ if (! enum_exists($this->enumClass)) {
+ return false;
+ }
+
+ return $intValue <= $this->calculateMaxValue();
+ }
+
+ private function calculateMaxValue(): int
+ {
+ assert($this->enumClass !== null);
+
+ /** @var array $cases */
+ $cases = $this->enumClass::cases();
+
+ return array_reduce(
+ $cases,
+ fn (int $carry, BackedEnum|UnitEnum $case): int => $carry | maskValue($case),
+ 0,
+ );
+ }
+}
diff --git a/src/TransistorServiceProvider.php b/src/TransistorServiceProvider.php
new file mode 100644
index 0000000..05caf87
--- /dev/null
+++ b/src/TransistorServiceProvider.php
@@ -0,0 +1,90 @@
+registerCommands();
+ $this->registerBladeDirectives();
+ $this->registerValidationRules();
+ $this->registerBlueprintMacros();
+ }
+
+ private function registerCommands(): void
+ {
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ InspectBitmaskCommand::class,
+ MakeBitmaskFlagsCommand::class,
+ ]);
+ }
+ }
+
+ private function registerBladeDirectives(): void
+ {
+ Blade::if('hasBitmaskFlag', fn (Bitmask $bitmask, int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag));
+
+ /** @param array $flags */
+ Blade::if('hasAnyBitmaskFlag', fn (Bitmask $bitmask, array $flags): bool => array_any(
+ $flags,
+ fn (int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag), // @phpstan-ignore argument.type
+ ));
+
+ /** @param array $flags */
+ Blade::if('hasAllBitmaskFlags', fn (Bitmask $bitmask, array $flags): bool => array_all(
+ $flags,
+ fn (int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag), // @phpstan-ignore argument.type
+ ));
+ }
+
+ private function registerValidationRules(): void
+ {
+ Validator::extend('bitmask', function (string $attribute, mixed $value, array $parameters): bool {
+ /** @var class-string<\BackedEnum|\UnitEnum>|null $enumClass */
+ $enumClass = $parameters[0] ?? null;
+
+ return (new ValidBitmask($enumClass))->passes($attribute, $value);
+ });
+ }
+
+ private function registerBlueprintMacros(): void
+ {
+ Blueprint::macro('tinyBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition {
+ /** @var Blueprint $this */
+ return $this->tinyInteger($column)->unsigned()->default(0);
+ });
+
+ Blueprint::macro('smallBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition {
+ /** @var Blueprint $this */
+ return $this->smallInteger($column)->unsigned()->default(0);
+ });
+
+ Blueprint::macro('mediumBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition {
+ /** @var Blueprint $this */
+ return $this->mediumInteger($column)->unsigned()->default(0);
+ });
+
+ Blueprint::macro('bitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition {
+ /** @var Blueprint $this */
+ return $this->unsignedInteger($column)->default(0);
+ });
+ }
+}
diff --git a/src/stubs/bitmask-flags.stub b/src/stubs/bitmask-flags.stub
new file mode 100644
index 0000000..dae0a81
--- /dev/null
+++ b/src/stubs/bitmask-flags.stub
@@ -0,0 +1,10 @@
+loadMigrationsFrom(__DIR__.'/database/migrations');
+});
+
+it('casts integer to bitmask on retrieval', function () {
+ User::create(['permissions' => 5, 'settings' => 3]);
+
+ $user = User::first();
+
+ expect($user->permissions)
+ ->toBeInstanceOf(Bitmask::class)
+ ->and($user->permissions->value())->toBe(5);
+});
+
+it('casts bitmask to integer on storage', function () {
+ $bitmask = Bitmask::make()
+ ->set(Permission::Read)
+ ->set(Permission::Delete);
+
+ User::create(['permissions' => $bitmask, 'settings' => 0]);
+
+ $this->assertDatabaseHas('users', [
+ 'permissions' => 5,
+ ]);
+});
+
+it('handles null values', function () {
+ User::unguard();
+ $user = new User(['permissions' => null, 'settings' => null]);
+ User::reguard();
+
+ expect($user->permissions)->toBeNull();
+});
+
+it('respects size parameter for tiny bitmask', function () {
+ User::create(['permissions' => 0, 'settings' => 127]);
+
+ $user = User::first();
+
+ expect($user->settings)
+ ->toBeInstanceOf(Bitmask::class)
+ ->and($user->settings->size()->value)->toBe(8);
+});
+
+it('preserves bitmask operations through save cycle', function () {
+ $user = User::create(['permissions' => 0, 'settings' => 0]);
+
+ $user->permissions = $user->permissions
+ ->set(Permission::Read)
+ ->set(Permission::Write);
+
+ $user->save();
+
+ $freshUser = User::find($user->id);
+
+ expect($freshUser->permissions->has(Permission::Read))->toBeTrue()
+ ->and($freshUser->permissions->has(Permission::Write))->toBeTrue()
+ ->and($freshUser->permissions->has(Permission::Delete))->toBeFalse();
+});
diff --git a/tests/Fixtures/Permission.php b/tests/Fixtures/Permission.php
new file mode 100644
index 0000000..bd231ec
--- /dev/null
+++ b/tests/Fixtures/Permission.php
@@ -0,0 +1,13 @@
+ BitmaskCast::class,
+ 'settings' => BitmaskCast::class.':tiny',
+ ];
+ }
+}
diff --git a/tests/HasBitmaskScopesSpec.php b/tests/HasBitmaskScopesSpec.php
new file mode 100644
index 0000000..be2cc49
--- /dev/null
+++ b/tests/HasBitmaskScopesSpec.php
@@ -0,0 +1,62 @@
+loadMigrationsFrom(__DIR__.'/database/migrations');
+
+ // Create test users with different permission combinations
+ User::create(['permissions' => Permission::Read->value, 'settings' => 0]); // 1
+ User::create(['permissions' => Permission::Read->value | Permission::Write->value, 'settings' => 0]); // 3
+ User::create(['permissions' => Permission::Admin->value, 'settings' => 0]); // 8
+ User::create(['permissions' => Permission::Read->value | Permission::Admin->value, 'settings' => 0]); // 9
+ User::create(['permissions' => 0, 'settings' => 0]); // 0
+});
+
+it('filters by single flag', function () {
+ $users = User::whereHasBitmaskFlag('permissions', Permission::Read)->get();
+
+ expect($users)->toHaveCount(3);
+});
+
+it('filters by all flags', function () {
+ $users = User::whereHasAllBitmaskFlags('permissions', [Permission::Read, Permission::Write])->get();
+
+ expect($users)->toHaveCount(1)
+ ->and($users->first()->permissions->value())->toBe(3);
+});
+
+it('filters by any flag', function () {
+ $users = User::whereHasAnyBitmaskFlag('permissions', [Permission::Write, Permission::Admin])->get();
+
+ expect($users)->toHaveCount(3);
+});
+
+it('filters by missing flag', function () {
+ $users = User::whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)->get();
+
+ expect($users)->toHaveCount(3);
+});
+
+it('filters by missing any flags', function () {
+ $users = User::whereDoesntHaveAnyBitmaskFlag('permissions', [Permission::Read, Permission::Write])->get();
+
+ expect($users)->toHaveCount(2);
+});
+
+it('works with integer values', function () {
+ $users = User::whereHasBitmaskFlag('permissions', 1)->get();
+
+ expect($users)->toHaveCount(3);
+});
+
+it('can chain multiple scopes', function () {
+ $users = User::whereHasBitmaskFlag('permissions', Permission::Read)
+ ->whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)
+ ->get();
+
+ expect($users)->toHaveCount(2);
+});
diff --git a/tests/InspectBitmaskCommandSpec.php b/tests/InspectBitmaskCommandSpec.php
new file mode 100644
index 0000000..4f6ff3b
--- /dev/null
+++ b/tests/InspectBitmaskCommandSpec.php
@@ -0,0 +1,69 @@
+artisan('bitmask:inspect', ['enum' => Permission::class])
+ ->expectsTable(
+ ['Case', 'Decimal', 'Binary'],
+ [
+ ['Read', '1', '0 0 0 0 0 0 0 1'],
+ ['Write', '2', '0 0 0 0 0 0 1 0'],
+ ['Delete', '4', '0 0 0 0 0 1 0 0'],
+ ['Admin', '8', '0 0 0 0 1 0 0 0'],
+ ]
+ )
+ ->assertSuccessful();
+});
+
+it('displays correct table for a unit enum', function () {
+ $this->artisan('bitmask:inspect', ['enum' => UnitPermission::class])
+ ->expectsTable(
+ ['Case', 'Decimal', 'Binary'],
+ [
+ ['Read', '1', '0 0 0 0 0 0 0 1'],
+ ['Write', '2', '0 0 0 0 0 0 1 0'],
+ ['Delete', '4', '0 0 0 0 0 1 0 0'],
+ ['Admin', '8', '0 0 0 0 1 0 0 0'],
+ ]
+ )
+ ->assertSuccessful();
+});
+
+it('displays value row with correct decimal and binary', function (int $value, string $expectedBinary) {
+ $this->artisan('bitmask:inspect', ['enum' => Permission::class, 'value' => $value])
+ ->expectsTable(
+ ['Case', 'Decimal', 'Binary'],
+ [
+ ['Read', '1', '0 0 0 0 0 0 0 1'],
+ ['Write', '2', '0 0 0 0 0 0 1 0'],
+ ['Delete', '4', '0 0 0 0 0 1 0 0'],
+ ['Admin', '8', '0 0 0 0 1 0 0 0'],
+ new TableSeparator,
+ ['Value', (string) $value, $expectedBinary],
+ ]
+ )
+ ->assertSuccessful();
+})->with([
+ 'single flag (Read)' => [1, '0 0 0 0 0 0 0 1'],
+ 'two flags (Read + Delete)' => [5, '0 0 0 0 0 1 0 1'],
+ 'three flags (Read + Write + Admin)' => [11, '0 0 0 0 1 0 1 1'],
+ 'all flags' => [15, '0 0 0 0 1 1 1 1'],
+ 'zero (no flags)' => [0, '0 0 0 0 0 0 0 0'],
+]);
+
+it('fails for non-existent enum', function () {
+ $this->artisan('bitmask:inspect', ['enum' => 'NonExistentEnum'])
+ ->expectsOutputToContain('not found')
+ ->assertFailed();
+});
+
+it('fails for invalid value', function () {
+ $this->artisan('bitmask:inspect', ['enum' => Permission::class, 'value' => 'invalid'])
+ ->expectsOutputToContain('non-negative integer')
+ ->assertFailed();
+});
diff --git a/tests/MakeBitmaskFlagsCommandSpec.php b/tests/MakeBitmaskFlagsCommandSpec.php
new file mode 100644
index 0000000..7d02342
--- /dev/null
+++ b/tests/MakeBitmaskFlagsCommandSpec.php
@@ -0,0 +1,98 @@
+enumPath = app_path('Enums');
+
+ if (File::isDirectory($this->enumPath)) {
+ File::deleteDirectory($this->enumPath);
+ }
+});
+
+afterEach(function () {
+ if (File::isDirectory($this->enumPath)) {
+ File::deleteDirectory($this->enumPath);
+ }
+
+ Prompt::fallbackWhen(false);
+});
+
+it('creates enum with correct structure', function () {
+ Prompt::fallbackWhen(true);
+
+ $this->artisan('make:bitmask-flags', ['name' => 'TestPermission'])
+ ->expectsQuestion('Flag 1', 'Read')
+ ->expectsQuestion('Flag 2', 'Write')
+ ->expectsQuestion('Flag 3', '')
+ ->assertSuccessful();
+
+ $filePath = app_path('Enums/TestPermission.php');
+ expect(File::exists($filePath))->toBeTrue();
+
+ $content = File::get($filePath);
+ expect($content)
+ ->toContain('namespace App\Enums;')
+ ->toContain('enum TestPermission: int')
+ ->toContain('case Read = 1 << 0;')
+ ->toContain('case Write = 1 << 1;');
+});
+
+it('creates enum in Enums namespace', function () {
+ Prompt::fallbackWhen(true);
+
+ $this->artisan('make:bitmask-flags', ['name' => 'NamespaceEnum'])
+ ->expectsQuestion('Flag 1', 'First')
+ ->expectsQuestion('Flag 2', '')
+ ->assertSuccessful();
+
+ $content = File::get(app_path('Enums/NamespaceEnum.php'));
+ expect($content)->toContain('namespace App\Enums;');
+});
+
+it('fails when no flags are provided', function () {
+ Prompt::fallbackWhen(true);
+
+ $this->artisan('make:bitmask-flags', ['name' => 'EmptyEnum'])
+ ->expectsQuestion('Flag 1', '');
+
+ // File should not be created when no flags provided
+ expect(File::exists(app_path('Enums/EmptyEnum.php')))->toBeFalse();
+});
+
+it('converts flag names to studly case', function () {
+ Prompt::fallbackWhen(true);
+
+ $this->artisan('make:bitmask-flags', ['name' => 'StudlyEnum'])
+ ->expectsQuestion('Flag 1', 'some_flag')
+ ->expectsQuestion('Flag 2', 'another-flag')
+ ->expectsQuestion('Flag 3', '')
+ ->assertSuccessful();
+
+ $content = File::get(app_path('Enums/StudlyEnum.php'));
+ expect($content)
+ ->toContain('case SomeFlag = 1 << 0;')
+ ->toContain('case AnotherFlag = 1 << 1;');
+});
+
+it('assigns correct bit shift values', function () {
+ Prompt::fallbackWhen(true);
+
+ $this->artisan('make:bitmask-flags', ['name' => 'ManyFlags'])
+ ->expectsQuestion('Flag 1', 'First')
+ ->expectsQuestion('Flag 2', 'Second')
+ ->expectsQuestion('Flag 3', 'Third')
+ ->expectsQuestion('Flag 4', 'Fourth')
+ ->expectsQuestion('Flag 5', '')
+ ->assertSuccessful();
+
+ $content = File::get(app_path('Enums/ManyFlags.php'));
+ expect($content)
+ ->toContain('case First = 1 << 0;')
+ ->toContain('case Second = 1 << 1;')
+ ->toContain('case Third = 1 << 2;')
+ ->toContain('case Fourth = 1 << 3;');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..4dce800
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,7 @@
+in(__DIR__);
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..a406593
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,33 @@
+loadMigrationsFrom(__DIR__.'/database/migrations');
+ }
+
+ protected function getEnvironmentSetUp($app): void
+ {
+ $app['config']->set('database.default', 'testing');
+ $app['config']->set('database.connections.testing', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+ }
+}
diff --git a/tests/ValidBitmaskSpec.php b/tests/ValidBitmaskSpec.php
new file mode 100644
index 0000000..1383ed7
--- /dev/null
+++ b/tests/ValidBitmaskSpec.php
@@ -0,0 +1,70 @@
+passes('permissions', 0))->toBeTrue()
+ ->and($rule->passes('permissions', 15))->toBeTrue()
+ ->and($rule->passes('permissions', 255))->toBeTrue();
+});
+
+it('fails for negative values', function () {
+ $rule = new ValidBitmask;
+
+ expect($rule->passes('permissions', -1))->toBeFalse();
+});
+
+it('fails for non-numeric values', function () {
+ $rule = new ValidBitmask;
+
+ expect($rule->passes('permissions', 'invalid'))->toBeFalse()
+ ->and($rule->passes('permissions', []))->toBeFalse()
+ ->and($rule->passes('permissions', null))->toBeFalse();
+});
+
+it('passes for valid bitmask within enum range', function () {
+ $rule = new ValidBitmask(Permission::class);
+
+ // All flags: Read(1) | Write(2) | Delete(4) | Admin(8) = 15
+ expect($rule->passes('permissions', 0))->toBeTrue()
+ ->and($rule->passes('permissions', 1))->toBeTrue()
+ ->and($rule->passes('permissions', 7))->toBeTrue()
+ ->and($rule->passes('permissions', 15))->toBeTrue();
+});
+
+it('fails for bitmask exceeding enum range', function () {
+ $rule = new ValidBitmask(Permission::class);
+
+ // Max valid is 15, so 16 should fail
+ expect($rule->passes('permissions', 16))->toBeFalse()
+ ->and($rule->passes('permissions', 255))->toBeFalse();
+});
+
+it('works as string validation rule', function () {
+ $validator = Validator::make(
+ ['permissions' => 7],
+ ['permissions' => 'bitmask:'.Permission::class]
+ );
+
+ expect($validator->passes())->toBeTrue();
+
+ $validator = Validator::make(
+ ['permissions' => 16],
+ ['permissions' => 'bitmask:'.Permission::class]
+ );
+
+ expect($validator->passes())->toBeFalse();
+});
+
+it('accepts numeric strings', function () {
+ $rule = new ValidBitmask;
+
+ expect($rule->passes('permissions', '15'))->toBeTrue()
+ ->and($rule->passes('permissions', '0'))->toBeTrue();
+});
diff --git a/tests/database/migrations/0001_01_01_000000_create_users_table.php b/tests/database/migrations/0001_01_01_000000_create_users_table.php
new file mode 100644
index 0000000..31db0b5
--- /dev/null
+++ b/tests/database/migrations/0001_01_01_000000_create_users_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->bitmask('permissions');
+ $table->tinyBitmask('settings');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('users');
+ }
+};