Lightweight ORM component with attribute-based relation management for Neuron-PHP framework. Provides a Rails-like interface for defining and working with database relationships using PHP 8.4 attributes.
- Attribute-Based Relations: Define relations using PHP 8 attributes
- Rails-Like API: Familiar interface for developers coming from Rails/Laravel
- Complete CRUD: Create, read, update, and delete with simple methods
- Dependent Cascade: Rails-style dependent destroy strategies for relations
- Lazy & Eager Loading: Optimize database queries automatically
- Multiple Relation Types: BelongsTo, HasMany, HasOne, BelongsToMany
- Fluent Query Builder: Chainable query methods with column selection and JOINs
- Raw & Grouped Predicates:
whereRaw, parenthesisedwhere(fn)groups,whereNotIn,whereColumn,having - Streaming Results:
cursor()andcursorRaw()walk large result sets without building an array - Transaction Support: Full ACID transaction support with callbacks
- Aggregate Functions: Built-in sum, avg, max, min methods with GROUP BY support
- Raw Results: Get raw arrays for aggregate queries and computed columns
- Pivot Table Management: Attach, detach, and sync methods for many-to-many relations
- Framework Independent: Works with existing PDO connections
- Database Migrations: Phinx-backed
db:migrate, rollback, status, seed, and generate commands - Lightweight: Focused on essential ORM features
- Well Tested: 88%+ code coverage with 186 tests
composer require neuron-php/ormuse Neuron\Orm\Model;
// Set the PDO connection for all models
Model::setPdo($pdo);use Neuron\Orm\Model;
use Neuron\Orm\Attributes\{Table, BelongsTo, BelongsToMany};
#[Table('posts')]
class Post extends Model
{
private ?int $_id = null;
private string $_title;
private string $_body;
private int $_authorId;
#[BelongsTo(User::class, foreignKey: 'author_id')]
private ?User $_author = null;
#[BelongsToMany(Category::class, pivotTable: 'post_categories')]
private array $_categories = [];
#[BelongsToMany(Tag::class, pivotTable: 'post_tags')]
private array $_tags = [];
// Implement fromArray() method
public static function fromArray(array $data): static
{
$post = new self();
$post->_id = $data['id'] ?? null;
$post->_title = $data['title'] ?? '';
$post->_body = $data['body'] ?? '';
$post->_authorId = $data['author_id'] ?? 0;
return $post;
}
// Getters and setters...
}// Find by ID
$post = Post::find(1);
// Access relations (lazy loading)
echo $post->author->username;
foreach ($post->categories as $category) {
echo $category->name;
}
// Eager loading (N+1 prevention)
$posts = Post::with(['author', 'categories', 'tags'])->all();
// Query builder
$posts = Post::where('status', 'published')
->with('author')
->orderBy('created_at', 'DESC')
->limit(10)
->get();
// Get all
$allPosts = Post::all();
// Count
$count = Post::where('status', 'published')->count();#[Table('posts')]
class Post extends Model
{
#[BelongsTo(User::class, foreignKey: 'author_id')]
private ?User $_author = null;
}
// Usage
$post = Post::find(1);
$authorName = $post->author->username;#[Table('users')]
class User extends Model
{
#[HasMany(Post::class, foreignKey: 'author_id')]
private array $_posts = [];
}
// Usage
$user = User::find(1);
foreach ($user->posts as $post) {
echo $post->title;
}#[Table('users')]
class User extends Model
{
#[HasOne(Profile::class, foreignKey: 'user_id')]
private ?Profile $_profile = null;
}
// Usage
$user = User::find(1);
echo $user->profile->bio;#[Table('posts')]
class Post extends Model
{
#[BelongsToMany(
Category::class,
pivotTable: 'post_categories',
foreignPivotKey: 'post_id',
relatedPivotKey: 'category_id'
)]
private array $_categories = [];
}
// Usage
$post = Post::find(1);
foreach ($post->categories as $category) {
echo $category->name;
}
// Attach new relationships
$post->relation('categories')->attach(3); // Attach single category
$post->relation('categories')->attach([4, 5]); // Attach multiple
// Detach relationships
$post->relation('categories')->detach(3); // Detach single
$post->relation('categories')->detach([4, 5]); // Detach multiple
$post->relation('categories')->detach(); // Detach all
// Sync relationships (replace all with new set)
$post->relation('categories')->sync([1, 2, 3]); // Keep only 1, 2, 3
$post->relation('categories')->sync([]); // Remove allThe query builder provides a fluent interface for building database queries:
// Where clauses
$posts = Post::where('status', 'published')
->where('views', '>', 100)
->get();
// Where in / not in
$posts = Post::whereIn('id', [1, 2, 3])->get();
$posts = Post::query()->whereNotIn('status', ['draft', 'archived'])->get();
// Or where
$posts = Post::where('status', 'published')
->orWhere('status', 'featured')
->get();
// A null value means IS NULL rather than a comparison against NULL
$posts = Post::query()->where('published_at', null)->get(); // IS NULL
$posts = Post::query()->where('published_at', '!=', null)->get(); // IS NOT NULL
// Order by
$posts = Post::orderBy('created_at', 'DESC')->get();
// Limit and offset
$posts = Post::limit(10)->offset(20)->get();
// Count
$count = Post::where('status', 'published')->count();
// First
$post = Post::where('slug', 'hello-world')->first();
// Combining methods
$posts = Post::where('status', 'published')
->with(['author', 'categories'])
->orderBy('created_at', 'DESC')
->limit(5)
->get();Some predicates have no fluent equivalent: SQL functions, interval arithmetic, and
comparisons between two columns. whereRaw injects the expression verbatim and binds
values separately.
// SQL functions and interval arithmetic
$recent = Post::query()
->whereRaw("created_at >= NOW() - INTERVAL '24 hours'")
->get();
// Placeholders are bound, never interpolated
$posts = Post::query()
->whereRaw('view_count BETWEEN ? AND ?', [100, 500])
->get();
// Compare two columns: where() would bind the second one as a string literal
$overdue = Order::query()
->whereColumn('NOW()', '>', 'due_at')
->get();The expression is injected as written, so never build it from user input — pass values through the bindings argument instead.
where() and orWhere() append to a flat list, so a AND b OR c is read by SQL as
(a AND b) OR c. To express a AND (b OR c), pass a callback: its clauses are wrapped
in parentheses.
// WHERE status = 'published' AND (author_id = 1 OR author_id = 2)
$posts = Post::query()
->where('status', 'published')
->where(function($query) {
$query->where('author_id', 1)
->orWhere('author_id', 2);
})
->get();
// Groups nest, and orWhere() takes a callback too
$posts = Post::query()
->whereNotNull('slug')
->orWhere(function($query) {
$query->where('status', 'draft')
->whereNull('published_at');
})
->get();Filter on aggregates after grouping:
$popular = Post::query()
->select(['author_id', 'COUNT(*) AS post_count'])
->groupBy('author_id')
->having('COUNT(*)', '>', 5)
->getRaw();
$busy = Post::query()
->select(['author_id', 'SUM(view_count) AS views'])
->groupBy('author_id')
->havingRaw('SUM(view_count) > ?', [10000])
->getRaw();get() and getRaw() build an array of every row, which is fine for bounded queries and
expensive for large ones. cursor() and cursorRaw() return generators that yield one row
at a time instead:
// One model at a time
foreach (Post::query()->where('status', 'published')->cursor() as $post) {
echo $post->getTitle();
}
// One raw row at a time, for joined projections and aggregates
foreach (Post::query()->select(['posts.*', 'users.username'])
->join('users', 'posts.author_id', '=', 'users.id')
->cursorRaw() as $row) {
echo $row['username'];
}Relations are not eager loaded while streaming: with() needs the whole set up front to
batch its queries, so touching a relation inside the loop would query once per row. Use
get() when you need with().
Breaking out of the loop early closes the cursor.
Select specific columns instead of fetching all columns:
// Select specific columns
$users = User::query()
->select(['id', 'username', 'email'])
->where('active', true)
->get();
// Add columns to existing selection
$users = User::query()
->select('id')
->addSelect(['username', 'email'])
->get();
// Raw SQL expressions
$posts = Post::query()
->select(['posts.*'])
->selectRaw('LENGTH(title) as title_length')
->get();
// Distinct results
$usernames = User::query()
->select('username')
->distinct()
->get();Perform SQL JOINs to combine data from multiple tables:
// INNER JOIN
$posts = Post::query()
->select(['posts.*', 'users.username'])
->join('users', 'posts.author_id', '=', 'users.id')
->where('posts.status', 'published')
->get();
// LEFT JOIN
$posts = Post::query()
->leftJoin('users', 'posts.author_id', '=', 'users.id')
->get();
// Multiple JOINs with aliases
$posts = Post::query()
->from('posts', 'p')
->select(['p.*', 'u.username', 'c.name as category_name'])
->join('users u', 'p.author_id', '=', 'u.id')
->leftJoin('categories c', 'p.category_id', '=', 'c.id')
->orderBy('p.created_at', 'DESC')
->get();
// CROSS JOIN
$combinations = Product::query()
->crossJoin('colors')
->get();Perform aggregate calculations directly in the query builder:
// Sum
$totalViews = Post::query()
->where('status', 'published')
->sum('view_count');
// Average
$avgAge = User::query()->avg('age');
// Maximum
$maxPrice = Product::query()->max('price');
// Minimum
$minPrice = Product::query()
->where('in_stock', true)
->min('price');Group results by one or more columns:
// Count posts by category
$results = Post::query()
->select(['category_id', 'COUNT(*) as post_count'])
->groupBy('category_id')
->get();
// Group by multiple columns
$results = Post::query()
->select(['category_id', 'status', 'COUNT(*) as count'])
->groupBy(['category_id', 'status'])
->get();
// With JOIN and aggregation
$results = Category::query()
->select(['categories.name', 'COUNT(posts.id) as post_count'])
->leftJoin('posts', 'categories.id', '=', 'posts.category_id')
->groupBy('categories.id')
->orderBy('post_count', 'DESC')
->get();
// Sum views by category
$results = Post::query()
->select(['category_id', 'SUM(view_count) as total_views'])
->groupBy('category_id')
->orderBy('total_views', 'DESC')
->get();When using aggregate functions or computed columns, use getRaw() to preserve the raw database results instead of hydrating them into models:
// Get raw results with aggregate columns preserved
$results = Category::query()
->select(['categories.name', 'COUNT(posts.id) as post_count'])
->leftJoin('posts', 'categories.id', '=', 'posts.category_id')
->groupBy('categories.id')
->orderBy('post_count', 'DESC')
->getRaw();
foreach ($results as $row) {
echo "{$row['name']}: {$row['post_count']} posts\n";
// $row is an array, not a model object
}
// Multiple aggregate functions
$stats = Post::query()
->select([
'category_id',
'COUNT(*) as count',
'SUM(view_count) as total_views',
'AVG(view_count) as avg_views'
])
->groupBy('category_id')
->getRaw();
// With complex queries
$report = User::query()
->select([
'users.role',
'COUNT(posts.id) as post_count',
'MAX(posts.created_at) as latest_post'
])
->leftJoin('posts', 'users.id', '=', 'posts.author_id')
->groupBy('users.role')
->getRaw();Note: getRaw() returns an array of associative arrays instead of model objects. This is useful when:
- Using aggregate functions (COUNT, SUM, AVG, etc.)
- Selecting computed columns that don't exist on the model
- Joining tables with custom column selections
- Optimizing performance by skipping model hydration
Atomically increment or decrement numeric columns:
// Increment view count by 1
Post::where('id', 1)->increment('view_count');
// Increment by specific amount
Post::where('id', 1)->increment('view_count', 5);
// Decrement
User::where('id', 1)->decrement('credits', 10);Update multiple records with a single query:
// Update all matching records
$affected = Post::where('status', 'draft')
->update(['status' => 'published']);
echo "Updated {$affected} posts";Execute multiple database operations atomically with full ACID support:
// Manual transaction control
Model::beginTransaction();
try {
$user = User::create(['username' => 'john']);
$profile = Profile::create(['user_id' => $user->getId()]);
Model::commit();
} catch (Exception $e) {
Model::rollBack();
throw $e;
}
// Transaction with callback (automatic commit/rollback)
$userId = Model::transaction(function() {
$user = User::create(['username' => 'jane']);
Profile::create([
'user_id' => $user->getId(),
'bio' => 'Hello world'
]);
return $user->getId();
});
// Check transaction status
if (Model::inTransaction()) {
echo "Currently in a transaction";
}// Begin a transaction
Model::beginTransaction();
// Commit the transaction
Model::commit();
// Rollback the transaction
Model::rollBack();
// Check if in transaction
$inTransaction = Model::inTransaction();
// Execute callback in transaction (auto commit/rollback)
$result = Model::transaction(function() {
// Your database operations
return $someValue;
});Prevent N+1 query problems by eager loading relations:
// Without eager loading (N+1 problem)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Triggers a query for each post
}
// With eager loading (2 queries total)
$posts = Post::with('author')->all();
foreach ($posts as $post) {
echo $post->author->name; // No additional queries
}
// Multiple relations
$posts = Post::with(['author', 'categories', 'tags'])->all();Create and save new records to the database:
// Using create() - creates and saves in one step
$user = User::create([
'username' => 'john',
'email' => 'john@example.com'
]);
// Using save() on a new instance
$user = new User();
$user->setUsername('jane');
$user->setEmail('jane@example.com');
$user->save();
// Using fromArray() and save()
$user = User::fromArray([
'username' => 'bob',
'email' => 'bob@example.com'
]);
$user->save();Update existing records:
// Using update() method
$user = User::find(1);
$user->update([
'email' => 'newemail@example.com'
]);
// Using setters and save()
$user = User::find(1);
$user->setEmail('anotheremail@example.com');
$user->save();
// Using fill() for mass assignment
$user = User::find(1);
$user->fill([
'username' => 'updated',
'email' => 'updated@example.com'
])->save();Delete records from the database:
// Simple delete (no cascade)
$user = User::find(1);
$user->delete();
// Destroy with dependent cascade
$user = User::find(1);
$user->destroy(); // Cascades to related records based on dependent strategy
// Destroy multiple by IDs
User::destroyMany([1, 2, 3]); // Returns count of deleted records
User::destroyMany(1); // Can also pass single ID
// Delete via query builder
Post::where('status', 'draft')->delete(); // Returns count of deleted recordsDefine what happens to related records when a parent is destroyed:
use Neuron\Orm\DependentStrategy;
DependentStrategy::Destroy // Call destroy() on each related record (cascades further)
DependentStrategy::DeleteAll // Delete with SQL (faster, no cascade)
DependentStrategy::Nullify // Set foreign key to NULL
DependentStrategy::Restrict // Prevent deletion if relations existuse Neuron\Orm\Attributes\{Table, HasMany, HasOne, BelongsToMany};
use Neuron\Orm\DependentStrategy;
#[Table('users')]
class User extends Model
{
// Destroy: Calls destroy() on each post (cascades to post's relations)
#[HasMany(Post::class, foreignKey: 'author_id', dependent: DependentStrategy::Destroy)]
private array $_posts = [];
// DeleteAll: Fast SQL delete of profile (no cascade)
#[HasOne(Profile::class, foreignKey: 'user_id', dependent: DependentStrategy::DeleteAll)]
private ?Profile $_profile = null;
// Restrict: Prevents user deletion if comments exist
#[HasMany(Comment::class, dependent: DependentStrategy::Restrict)]
private array $_comments = [];
}
#[Table('posts')]
class Post extends Model
{
// DeleteAll: Remove pivot table entries only (genres remain)
#[BelongsToMany(Category::class, pivotTable: 'post_categories', dependent: DependentStrategy::DeleteAll)]
private array $_categories = [];
// Nullify: Set comment.post_id = NULL instead of deleting
#[HasMany(Comment::class, dependent: DependentStrategy::Nullify)]
private array $_comments = [];
}// With Destroy strategy
$user = User::find(1);
$user->destroy(); // Deletes user, all posts, AND all post categories (nested cascade)
// With DeleteAll strategy
$post = Post::find(1);
$post->destroy(); // Deletes post AND pivot entries, but NOT the categories themselves
// With Nullify strategy
$post = Post::find(1);
$post->destroy(); // Deletes post, sets comment.post_id = NULL for all comments
// With Restrict strategy
try {
$user = User::find(1);
$user->destroy(); // Throws RelationException if user has comments
} catch (RelationException $e) {
echo "Cannot delete user: " . $e->getMessage();
}// delete() - Simple deletion, NO cascade
$user = User::find(1);
$user->delete(); // Only deletes user, leaves posts orphaned
// destroy() - Respects dependent strategies
$user = User::find(1);
$user->destroy(); // Cascades to related records based on dependent attributeDefines the database table for the model.
#[Table('posts', primaryKey: 'id')]
class Post extends Model {}Parameters:
name(string): Table nameprimaryKey(string, optional): Primary key column name (default: 'id')
Maps a property to a database column (optional, for explicit mapping).
#[Column(name: 'email_address', type: 'string', nullable: false)]
private string $_email;Parameters:
name(string, optional): Database column name if different from propertytype(string, optional): Data type hint (string, int, float, bool, datetime, json)nullable(bool, optional): Whether the column can be null (default: false)
Defines a belongs-to (many-to-one) relationship.
#[BelongsTo(User::class, foreignKey: 'author_id', ownerKey: 'id')]
private ?User $_author = null;Parameters:
relatedModel(string): Related model class nameforeignKey(string, optional): Foreign key column name (default: property_name_id)ownerKey(string, optional): Owner key column name (default: 'id')
Defines a has-many (one-to-many) relationship.
#[HasMany(Post::class, foreignKey: 'author_id', localKey: 'id')]
private array $_posts = [];Parameters:
relatedModel(string): Related model class nameforeignKey(string, optional): Foreign key on related tablelocalKey(string, optional): Local key column name (default: 'id')
Defines a has-one (one-to-one) relationship.
#[HasOne(Profile::class, foreignKey: 'user_id', localKey: 'id')]
private ?Profile $_profile = null;Parameters:
relatedModel(string): Related model class nameforeignKey(string, optional): Foreign key on related tablelocalKey(string, optional): Local key column name (default: 'id')
Defines a belongs-to-many (many-to-many) relationship.
#[BelongsToMany(
Category::class,
pivotTable: 'post_categories',
foreignPivotKey: 'post_id',
relatedPivotKey: 'category_id',
parentKey: 'id',
relatedKey: 'id'
)]
private array $_categories = [];Parameters:
relatedModel(string): Related model class namepivotTable(string, optional): Pivot table name (auto-generated if not provided)foreignPivotKey(string, optional): Foreign key in pivot table for this modelrelatedPivotKey(string, optional): Foreign key in pivot table for related modelparentKey(string, optional): Parent key column name (default: 'id')relatedKey(string, optional): Related key column name (default: 'id')
- PHP 8.4 or higher
- PDO extension
- neuron-php/core
- neuron-php/data
- neuron-php/cli
- robmorgan/phinx
The ORM component owns the Phinx bridge (Neuron\Orm\Database\MigrationManager) and the migrate-family CLI commands:
./vendor/bin/neuron db:migration:generate CreateUsersTable
./vendor/bin/neuron db:migrate
./vendor/bin/neuron db:migrate:status
./vendor/bin/neuron db:rollback
./vendor/bin/neuron db:seedConfigure database: and migrations: in config/neuron.yaml. CMS still authors and copies framework migration files; this package runs them.
The ORM includes comprehensive test coverage:
# Run tests
./vendor/bin/phpunit tests
# Run tests with coverage
./vendor/bin/phpunit tests --coverage-text --coverage-filter=srcMIT