luxid / rocket
Modern ORM for Luxid Framework with PHP 8 attributes
Requires
- php: ^8.0
- ext-pdo: *
- fakerphp/faker: ^1.23
Requires (Dev)
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^9.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Rocket ORM
Attribute-driven ORM for the Luxid PHP Framework.
⚠️ Pre-release: APIs are unstable and subject to change.
About
Rocket maps PHP classes to database tables using PHP 8 attributes, so the schema lives beside the properties it describes rather than in a separate mapping file. It ships an Active Record entity, a fluent query builder, migrations, relations, validation rules and database seeding.
Rocket has no dependency on the rest of Luxid and can be used on its own.
Installation
composer require luxid/rocket
Requires PHP 8.1+ and ext-pdo.
Connecting
use Rocket\Connection\Connection; Connection::configure([ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=luxid', 'user' => 'root', 'password' => '', ]);
configure() records the settings without opening a socket; the connection is
established the first time a query needs it, so an application that never
touches the database never pays for the handshake.
Use initialize() instead when you want to connect eagerly and fail fast.
Entities
use Rocket\ORM\Entity; use Rocket\Attributes\Entity as EntityAttr; use Rocket\Attributes\Column; use Rocket\Attributes\Relations\HasMany; use Rocket\Attributes\Rules\{Required, Email, Min, Unique}; #[EntityAttr(table: 'users')] class User extends Entity { #[Column(primary: true, autoIncrement: true)] public int $id = 0; #[Column] #[Required] #[Email] #[Unique] public string $email = ''; #[Column(hidden: true)] #[Required] #[Min(8)] public string $password = ''; #[Column(autoCreate: true)] public string $created_at = ''; #[HasMany(Post::class, 'user_id', 'id')] protected $posts; }
#[Column(hidden: true)] keeps a value out of toArray() and out of JSON
responses. autoCreate and autoUpdate mark timestamp columns the database
manages.
Reading and writing
$user = User::find(1); $user = User::findOne(['email' => 'jhay@luxid.dev']); $users = User::findAll(['active' => 1], ['created_at' => 'DESC'], limit: 10); $result = User::create(['email' => 'jhay@luxid.dev', 'password' => 'secret123']); if ($result->failed()) { return $result->errors(); } $user->update(['email' => 'new@luxid.dev']); $user->delete();
Relations are loaded on first access and cached for the life of the entity:
foreach ($user->posts as $post) { echo $post->title; }
Query builder
$posts = Post::query() ->where('status', 'published') ->where(function ($q) { $q->where('featured', 1)->orWhere('views', '>=', 1000); }) ->whereIn('category_id', [1, 2, 3]) ->whereBetween('created_at', $from, $to) ->orderBy('created_at', 'DESC') ->limit(20) ->all();
Values are always bound as parameters.
Identifiers are validated, not bound
No database driver can bind a table or column name, so Rocket validates every
identifier against a strict pattern before it reaches SQL. Passing user input
straight to orderBy() or select() throws rather than building the query:
Post::query()->orderBy($_GET['sort']); // InvalidArgumentException
When the sort column genuinely comes from a request, name the columns a request is allowed to choose:
use Rocket\Query\QueryFilter; QueryFilter::sort( Post::query(), $request->query(), sortable: ['title', 'created_at', 'views'], default: 'created_at' );
QueryFilter also handles filtering and pagination, escaping LIKE
metacharacters and clamping the page size so a request cannot ask for the whole
table:
$query = Post::query(); QueryFilter::apply($query, [ 'status' => ['column' => 'status', 'values' => ['draft', 'published']], 'q' => ['column' => ['title', 'body'], 'operator' => 'LIKE'], ], $request->query()); QueryFilter::paginate($query, $request->query());
Raw conditions
whereRaw() is the escape hatch for expressions the builder cannot model. The
fragment is trusted verbatim, so never build it from request data — pass values
through the bindings argument:
$query->whereRaw('published_at > :since', ['since' => $since]);
Validation
Rules are attributes on the property they guard, and every rule implements
Rocket\Attributes\Rules\Rule:
| Rule | Purpose |
|---|---|
#[Required] |
Value must be present; 0 and "0" count as present |
#[Email] |
Must be a valid email address |
#[Min(n)] / #[Max(n)] |
Character count for strings, value for numbers, count for arrays |
#[In([...])] |
Must be one of a fixed set, compared strictly |
#[Unique] |
No other row may hold this value |
if (!$user->validate()) { return $user->getErrors(); }
#[Unique] is bound to the column it decorates by the metadata parser, so an
entity with several unique columns checks each against its own.
Migrations
use Rocket\Migration\{Migration, Rocket}; class m00001_create_users_table extends Migration { public function up(): void { Rocket::table('users', function ($column) { $column->id('id'); $column->string('email')->unique(); $column->string('password'); $column->timestamps(); }); } public function down(): void { Rocket::drop('users'); } }
Tables are created as InnoDB with utf8mb4 and utf8mb4_unicode_ci, so the
full Unicode range — emoji included — stores correctly regardless of the
server's defaults.
Seeding
use Rocket\Seed\Seeder; class UserSeeder extends Seeder { public function run(): void { User::create([ 'email' => 'admin@example.com', 'password' => 'admin12345', ]); } }
Testing
composer install
composer test
License
MIT.