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
- 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
- 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
$posts = Post::whereIn('id', [1, 2, 3])->get();
// Or where
$posts = Post::where('status', 'published')
->orWhere('status', 'featured')
->get();
// 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();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
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