preflow / data
Preflow data — storage drivers, models, migrations
Requires
- php: >=8.4
- ext-pdo: *
- ext-pdo_sqlite: *
- preflow/core: ^0.1 || @dev
- preflow/validation: ^0.1 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-18 00:19:49 UTC
README
Multi-storage data layer for Preflow. Supports JSON files and SQLite out of the box; different models can use different backends simultaneously.
Installation
composer require preflow/data
Requires PHP 8.4+, ext-pdo, ext-pdo_sqlite.
What it does
Models are annotated with PHP attributes. DataManager is the single entry point — it reads model metadata via reflection, selects the right storage driver, and returns typed results. QueryBuilder provides a fluent API for filtering, sorting, searching, and pagination. Migrations are handled by Schema + Table + Migrator.
API
Attributes
| Attribute | Target | Description |
|---|---|---|
#[Entity(table: 'posts', storage: 'sqlite')] |
Class | Maps model to a table/collection and a named driver. storage defaults to 'default'. |
#[Id] |
Property | Marks the primary key field. Works on any property — use string $uuid for UUIDs or int $id for auto-increment. |
#[Field(searchable: true)] |
Property | Marks a field; searchable: true includes it in full-text search. |
#[Timestamps] |
Class | Adds created_at / updated_at handling. |
Model
$model->fill(array $data): void // hydrate from associative array $model->toArray(): array // export public properties
DataManager
$dm->find(Post::class, $id): ?Post $dm->query(Post::class): QueryBuilder<Post> $dm->save(Model $model): void // INSERT when ID is empty, UPDATE otherwise $dm->insert(Model $model): void // explicit INSERT; reads back lastInsertId() $dm->update(Model $model): void // explicit UPDATE $dm->delete(Post::class, $id): void // delete by class + ID $dm->delete($model): void // delete by model instance $dm->raw(string $sql, array $bindings, string $storage): array // raw SQL query
save() detects an empty ID field and automatically issues an INSERT, then reads back lastInsertId() into the model — no need to pre-generate IDs for auto-increment tables.
QueryBuilder
All methods return $this for chaining except the terminal methods.
->where('status', 'published') ->where('views', '>', 100) ->orWhere('featured', true) ->orderBy('created_at', SortDirection::Desc) ->limit(10)->offset(20) ->search('php') // searches all #[Field(searchable: true)] fields ->get(): ResultSet // all results ->first(): ?Model ->paginate(perPage: 15, currentPage: 2): PaginatedResult
Storage drivers
| Class | Backend |
|---|---|
JsonFileDriver |
One .json file per record at {basePath}/{table}/{id}.json |
SqliteDriver |
PDO-based SQLite via PdoDriver + QueryCompiler |
Migrations
abstract class Migration { abstract public function up(Schema $schema): void; public function down(Schema $schema): void {} }
Schema methods: create(string $table, callable $callback), drop(string $table).
Table builder: uuid, string, text, integer, boolean, json, timestamps, nullable(), primary(), index().
Auto-increment IDs
Place #[Id] on an int property and leave it at 0 (or unset). save() / insert() will issue an INSERT and read the generated ID back via lastInsertId().
#[Entity(table: 'comments', storage: 'sqlite')] final class Comment extends Model { #[Id] public int $id = 0; #[Field] public string $body = ''; } $comment = new Comment(); $comment->body = 'Great post!'; $dm->save($comment); echo $comment->id; // e.g. 42 — populated after insert
Raw SQL
Use raw() to run a query that the fluent builder cannot express. Results are returned as plain arrays.
$rows = $dm->raw( 'SELECT p.*, COUNT(c.id) AS comment_count FROM posts p LEFT JOIN comments c ON c.post_id = p.id GROUP BY p.id', [], 'sqlite', );
Usage
Model:
use Preflow\Data\{Model, ModelMetadata}; use Preflow\Data\Attributes\{Entity, Id, Field, Timestamps}; #[Entity(table: 'posts', storage: 'sqlite')] #[Timestamps] final class Post extends Model { #[Id] public string $id; #[Field(searchable: true)] public string $title; #[Field(searchable: true)] public string $body; public string $status = 'draft'; }
Querying:
// Find by ID $post = $dm->find(Post::class, 'abc-123'); // Filtered query $posts = $dm->query(Post::class) ->where('status', 'published') ->orderBy('created_at', SortDirection::Desc) ->paginate(perPage: 10, currentPage: 1); // Full-text search across searchable fields $results = $dm->query(Post::class)->search('preflow')->get();
Saving:
$post = new Post(); $post->fill(['id' => 'abc-123', 'title' => 'Hello', 'body' => '...', 'status' => 'published']); $dm->save($post);
Migration:
use Preflow\Data\Migration\{Migration, Schema}; final class CreatePostsTable extends Migration { public function up(Schema $schema): void { $schema->create('posts', function (Table $t) { $t->uuid('id')->primary(); $t->string('title')->index(); $t->text('body'); $t->string('status'); $t->timestamps(); }); } public function down(Schema $schema): void { $schema->drop('posts'); } }
Multi-storage setup:
$dm = new DataManager([ 'default' => new JsonFileDriver(basePath: '/storage/data'), 'sqlite' => new SqliteDriver(new \PDO('sqlite:/storage/db.sqlite')), ]);
Models with #[Entity(storage: 'sqlite')] use SQLite; all others fall back to 'default'.