Skip to content
Neuron-PHPPublic

About

Lightweight ORM with attribute-based relations.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

CI codecov

Neuron ORM

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.

Features

  • 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, parenthesised where(fn) groups, whereNotIn, whereColumn, having
  • Streaming Results: cursor() and cursorRaw() 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

Installation

composer require neuron-php/orm

Quick Start

1. Set up PDO connection

use Neuron\Orm\Model;

// Set the PDO connection for all models
Model::setPdo($pdo);

2. Define your models with attributes

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

3. Use Rails-like syntax

// 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();

Relation Types

BelongsTo (Many-to-One)

#[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;

HasMany (One-to-Many)

#[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;
}

HasOne (One-to-One)

#[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;

BelongsToMany (Many-to-Many)

#[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 all

Query Builder

The 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();

Raw Predicates

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.

Grouped Predicates

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();

HAVING

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();

Streaming Large Result Sets

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.

Column Selection

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();

JOIN Support

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();

Aggregate Functions

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 BY

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();

Raw Results

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

Increment & Decrement

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);

Batch Updates

Update multiple records with a single query:

// Update all matching records
$affected = Post::where('status', 'draft')
    ->update(['status' => 'published']);

echo "Updated {$affected} posts";

Transactions

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";
}

Transaction Methods

// 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;
});

Eager Loading

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();

Creating Records

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();

Updating Records

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();

Deleting Records

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 records

Dependent Cascade Strategies

Define what happens to related records when a parent is destroyed:

Available Strategies

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 exist

Using Dependent Strategies

use 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 = [];
}

Example Usage

// 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 vs Destroy

// 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 attribute

Attribute Reference

Table

Defines the database table for the model.

#[Table('posts', primaryKey: 'id')]
class Post extends Model {}

Parameters:

  • name (string): Table name
  • primaryKey (string, optional): Primary key column name (default: 'id')

Column

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 property
  • type (string, optional): Data type hint (string, int, float, bool, datetime, json)
  • nullable (bool, optional): Whether the column can be null (default: false)

BelongsTo

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 name
  • foreignKey (string, optional): Foreign key column name (default: property_name_id)
  • ownerKey (string, optional): Owner key column name (default: 'id')

HasMany

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 name
  • foreignKey (string, optional): Foreign key on related table
  • localKey (string, optional): Local key column name (default: 'id')

HasOne

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 name
  • foreignKey (string, optional): Foreign key on related table
  • localKey (string, optional): Local key column name (default: 'id')

BelongsToMany

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 name
  • pivotTable (string, optional): Pivot table name (auto-generated if not provided)
  • foreignPivotKey (string, optional): Foreign key in pivot table for this model
  • relatedPivotKey (string, optional): Foreign key in pivot table for related model
  • parentKey (string, optional): Parent key column name (default: 'id')
  • relatedKey (string, optional): Related key column name (default: 'id')

Requirements

  • PHP 8.4 or higher
  • PDO extension
  • neuron-php/core
  • neuron-php/data
  • neuron-php/cli
  • robmorgan/phinx

Database Migrations

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:seed

Configure database: and migrations: in config/neuron.yaml. CMS still authors and copies framework migration files; this package runs them.

Testing

The ORM includes comprehensive test coverage:

# Run tests
./vendor/bin/phpunit tests

# Run tests with coverage
./vendor/bin/phpunit tests --coverage-text --coverage-filter=src

License

MIT

About

Lightweight ORM with attribute-based relations.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages