Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 12 additions & 29 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
229 changes: 229 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<p align="center">
<img src="https://banners.beyondco.de/gksh%2Ftransistor.png?theme=light&packageManager=composer+require&packageName=gksh%2Ftransistor&pattern=circuitBoard&style=style_1&description=Bitwise+affordances+in+Laravel&md=1&showWatermark=0&fontSize=100px&images=lightning-bolt&widths=100&heights=100" alt="Transistor banner">
</p>

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

[![Latest Version on Packagist](https://img.shields.io/packagist/v/gksh/transistor.svg?style=flat-square)](https://packagist.org/packages/gksh/transistor)
[![PHP Version](https://img.shields.io/packagist/php-v/gksh/transistor.svg?style=flat-square)](https://packagist.org/packages/gksh/transistor)
[![License](https://img.shields.io/packagist/l/gksh/transistor.svg?style=flat-square)](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)
<span>Administrator</span>
@endif

@hasAnyBitmaskFlag($user->permissions, [Permission::Write, Permission::Admin])
<button>Edit</button>
@endif

@hasAllBitmaskFlags($user->permissions, [Permission::Read, Permission::Write])
<span>Full Access</span>
@endif
```

## Requirements

- PHP 8.2+
- Laravel 11.x or 12.x

## License

MIT
62 changes: 62 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -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#'
Loading