SQL ORM support for Forge Kernel. Provides attribute-based models, fluent query builder, relationships, repository pattern, query caching, and full transaction support for SQLite, MySQL, and PostgreSQL.
ForgeSqlOrm provides comprehensive Object-Relational Mapping (ORM) support for Forge Kernel applications. It offers model-based database access, fluent query builder, relationship support, repository pattern, and query caching.
Core Module: ForgeSqlOrm is a core module (type: 'core', order: 1), providing essential ORM functionality for Forge Kernel applications. It depends on ForgeDatabaseSql for database connectivity.
ForgeSqlOrm is built with clean architecture, performance, and developer experience in mind.
ForgeSqlOrm uses a model-based approach:
QueryBuilder implements an immutable, fluent interface:
Repository pattern provides clean data access:
Efficient relationship loading:
ForgeSqlOrm is a core module and is typically available by default. It can also be installed via ForgePackageManager.
# Install with wizard (interactive)
php forge.php package:install-module
# Install directly (skip wizard)
php forge.php package:install-module --module=ForgeSqlOrm
# Install specific version
php forge.php package:install-module --module=ForgeSqlOrm@0.1.8
ForgeSqlOrm depends on ForgeDatabaseSql for database connectivity. Ensure ForgeDatabaseSql is installed and configured.
The module automatically registers QueryBuilderInterface and QueryCache:
public function register(Container $container): void
{
// Bind QueryBuilderInterface to QueryBuilder
$container->bind(QueryBuilderInterface::class, function ($c) {
return new QueryBuilder($c->get(DatabaseConnectionInterface::class));
});
// Register QueryCache as singleton
$container->singleton(QueryCache::class, function () {
return new QueryCache(3600); // TTL: 3600 seconds
});
}
Models in ForgeSqlOrm extend the base Model class and use attributes to define their structure.
ForgeSqlOrm uses PHP 8 attributes to define model structure and behavior.
Define the database table name for the model:
#[Table("users")]
class User extends Model
{
// ...
}
Define a column with type casting:
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $email;
#[Column(cast: Cast::JSON)]
public ?UserMetadataDto $metadata;
Column attributes support:
Exclude fields from toArray() and JSON serialization:
#[ProtectedFields(['password'])]
class User extends Model
{
#[Column(cast: Cast::STRING)]
public string $password; // Excluded from toArray()/JSON
}
Hide a field from serialization:
#[Hidden]
#[Column(cast: Cast::STRING)]
public string $internal_field; // Hidden from toArray()/JSON
Models are defined by extending the Model base class and using attributes.
use App\Modules\ForgeSqlOrm\ORM\Attributes\Table;
use App\Modules\ForgeSqlOrm\ORM\Attributes\Column;
use App\Modules\ForgeSqlOrm\ORM\Model;
use App\Modules\ForgeSqlOrm\ORM\Values\Cast;
#[Table("users")]
class User extends Model
{
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $email;
}
Use traits for common functionality:
use App\Modules\ForgeSqlOrm\Traits\HasTimeStamps;
use App\Modules\ForgeSqlOrm\Traits\HasMetaData;
#[Table("profiles")]
class Profile extends Model
{
use HasTimeStamps; // Adds created_at, updated_at
use HasMetaData; // Adds metadata JSON column
#[Column(primary: true, cast: Cast::INT)]
public int $id;
}
use App\Modules\ForgeSqlOrm\ORM\Values\Relate;
use App\Modules\ForgeSqlOrm\ORM\Values\RelationKind;
#[Table("users")]
class User extends Model
{
use HasTimeStamps;
use CanLoadRelations;
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Relate(RelationKind::HasOne, Profile::class, "user_id")]
public function profile(): Relation
{
return self::describe(__FUNCTION__);
}
}
#[Table("users")]
#[ProtectedFields(['password'])]
class User extends Model
{
#[Column(cast: Cast::STRING)]
public string $password; // Excluded from toArray()/JSON
}
Models can integrate with ForgeMultiTenant:
use App\Modules\ForgeMultiTenant\Attributes\TenantScoped;
use App\Modules\ForgeMultiTenant\Traits\TenantScopedTrait;
#[TenantScoped]
#[Table("posts")]
class Post extends Model
{
use HasTimeStamps;
use HasMetaData;
use TenantScopedTrait;
#[Column(primary: true, cast: Cast::STRING)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $tenant_id;
}
Models provide methods for creating, updating, deleting, and querying records.
Insert a new record or update an existing one:
$user = new User();
$user->email = 'user@example.com';
$user->password = 'hashed_password';
$user->save(); // Inserts new record
$user->email = 'newemail@example.com';
$user->save(); // Updates existing record
The save() method:
Soft delete or force delete a record:
$user = User::query()->id(1)->first();
$user->delete(); // Soft delete (sets deleted_at)
// Force delete (if soft deletes disabled)
$user->forceDelete();
Convert model to array (respects protected fields):
$user = User::query()->id(1)->first();
$array = $user->toArray(); // Excludes protected fields
JSON serialization (respects protected fields):
$user = User::query()->id(1)->first();
$json = json_encode($user); // Uses jsonSerialize()
Create model instance from database row:
$row = ['id' => 1, 'email' => 'user@example.com'];
$user = User::fromRow($row); // Creates User instance with type casting
QueryBuilder provides a fluent, immutable interface for building database queries.
use App\Modules\ForgeSqlOrm\ORM\QueryBuilder;
use Forge\Core\Contracts\Database\QueryBuilderInterface;
// Inject QueryBuilderInterface
public function __construct(
private readonly QueryBuilderInterface $builder
) {}
// Basic query
$results = $this->builder
->table('users')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->limit(10)
->get();
// Simple where
$results = $this->builder->table('users')
->where('email', '=', 'user@example.com')
->get();
// Where with IN
$results = $this->builder->table('users')
->whereIn('id', [1, 2, 3])
->get();
// Where with NOT IN
$results = $this->builder->table('users')
->whereNotIn('status', ['deleted', 'banned'])
->get();
// Where NULL
$results = $this->builder->table('users')
->whereNull('deleted_at')
->get();
// Where NOT NULL
$results = $this->builder->table('users')
->whereNotNull('email')
->get();
$results = $this->builder->table('users')
->select('id', 'email', 'identifier')
->where('status', '=', 'active')
->get();
$results = $this->builder->table('users')
->orderBy('created_at', 'DESC')
->limit(10)
->offset(20)
->get();
// Count
$count = $this->builder->table('users')
->where('status', '=', 'active')
->count();
// Sum
$total = $this->builder->table('orders')
->sum('amount');
// Average
$avg = $this->builder->table('orders')
->avg('amount');
// Min/Max
$min = $this->builder->table('orders')->min('amount');
$max = $this->builder->table('orders')->max('amount');
// Insert
$id = $this->builder->table('users')
->insert(['email' => 'user@example.com', 'password' => 'hash']);
// Update
$this->builder->table('users')
->where('id', '=', 1)
->update(['email' => 'newemail@example.com']);
// Delete
$this->builder->table('users')
->where('id', '=', 1)
->delete();
$results = $this->builder->table('users')
->where('id', '=', 1)
->lockForUpdate()
->get();
ModelQuery provides a model-specific query builder that returns Model instances.
// Get all users
$users = User::query()->get();
// Get first user
$user = User::query()->first();
// Find by ID
$user = User::query()->id(1)->first();
// Where clause
$users = User::query()
->where('status', '=', 'active')
->get();
// Where NULL
$users = User::query()
->whereNull('deleted_at')
->get();
// Only trashed (soft deleted)
$deletedUsers = User::query()->onlyTrashed()->get();
// Soft delete
$user = User::query()->id(1)->first();
$user->softDelete();
// Force delete
User::query()->id(1)->forceDelete();
// Eager load relationships
$users = User::query()
->with('profile')
->get();
// Nested relationships
$users = User::query()
->with('profile', 'posts')
->get();
// Insert
User::query()->insert(['email' => 'user@example.com']);
// Update
User::query()
->where('id', '=', 1)
->update(['email' => 'newemail@example.com']);
ForgeSqlOrm supports three types of relationships: HasOne, HasMany, and BelongsTo.
Use the #[Relate] attribute to define relationships:
use App\Modules\ForgeSqlOrm\ORM\Values\Relate;
use App\Modules\ForgeSqlOrm\ORM\Values\RelationKind;
#[Table("users")]
class User extends Model
{
use CanLoadRelations;
// HasOne relationship
#[Relate(RelationKind::HasOne, Profile::class, "user_id")]
public function profile(): Relation
{
return self::describe(__FUNCTION__);
}
// HasMany relationship
#[Relate(RelationKind::HasMany, Post::class, "user_id")]
public function posts(): Relation
{
return self::describe(__FUNCTION__);
}
}
#[Table("posts")]
class Post extends Model
{
// BelongsTo relationship
#[Relate(RelationKind::BelongsTo, User::class, "user_id")]
public function user(): Relation
{
return self::describe(__FUNCTION__);
}
}
Eager loading prevents N+1 queries:
// Eager load relationships
$users = User::query()
->with('profile')
->get();
// Access relationship
foreach ($users as $user) {
$profile = $user->profile; // Already loaded, no query
}
Lazy loading loads relationships on demand:
$user = User::query()->id(1)->first();
$user->load('profile'); // Loads profile relationship
// Load nested relationships
$users = User::query()
->with('profile', 'posts')
->get();
ForgeSqlOrm automatically casts database values to PHP types using the Cast enum.
enum Cast: string
{
case INT = "int";
case FLOAT = "float";
case BOOL = "bool";
case STRING = "string";
case JSON = "json";
case DATE = "date";
case DATETIME = "datetime";
case TIMESTAMP = "timestamp";
case ENUM = "enum";
}
#[Table("users")]
class User extends Model
{
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $email;
#[Column(cast: Cast::JSON)]
public ?UserMetadataDto $metadata; // Casts to DTO
#[Column(cast: Cast::DATETIME)]
public ?DateTimeImmutable $created_at;
}
JSON columns can be cast to DTOs:
#[Column(cast: Cast::JSON)]
public ?UserMetadataDto $metadata;
// Automatically casts JSON string to UserMetadataDto instance
// when loading from database
#[Column(cast: Cast::ENUM)]
public ?UserStatus $status; // BackedEnum instance
Date types are cast to DateTimeImmutable:
#[Column(cast: Cast::DATE)]
public ?DateTimeImmutable $birth_date;
#[Column(cast: Cast::DATETIME)]
public ?DateTimeImmutable $created_at;
#[Column(cast: Cast::TIMESTAMP)]
public ?DateTimeImmutable $updated_at;
ForgeSqlOrm provides traits for common model functionality.
Automatically adds created_at and updated_at columns:
use App\Modules\ForgeSqlOrm\Traits\HasTimeStamps;
#[Table("users")]
class User extends Model
{
use HasTimeStamps;
// Automatically adds:
// public ?DateTimeImmutable $created_at = null;
// public ?DateTimeImmutable $updated_at = null;
}
The save() method automatically sets these timestamps:
Automatically adds a metadata JSON column:
use App\Modules\ForgeSqlOrm\Traits\HasMetaData;
#[Table("profiles")]
class Profile extends Model
{
use HasMetaData;
// Automatically adds:
// public ?array $metadata = null;
}
ForgeSqlOrm supports soft deletes, allowing records to be marked as deleted without actually removing them from the database.
Models use a deleted_at column (configurable via SOFT_DELETE_COLUMN constant):
// Default soft delete column
protected const string SOFT_DELETE_COLUMN = 'deleted_at';
$user = User::query()->id(1)->first();
$user->delete(); // Sets deleted_at timestamp
// Only trashed (soft deleted) records
$deletedUsers = User::query()->onlyTrashed()->get();
// Normal queries exclude soft deleted records
$activeUsers = User::query()->get(); // Excludes soft deleted
// Force delete (permanently remove)
$user = User::query()->id(1)->first();
$user->forceDelete();
// Or via ModelQuery
User::query()->id(1)->forceDelete();
The repository pattern provides a clean data access layer with built-in caching.
interface Repository
{
public function create(mixed $data): Model;
public function update(int|string $id, mixed $data): ?Model;
public function delete(int|string $id): bool;
public function find(int|string $id): ?Model;
public function findBy(string $field, mixed $value): ?Model;
public function findAll(): array;
}
Extend RecordRepository for common CRUD operations:
use App\Modules\ForgeSqlOrm\ORM\RecordRepository;
final class UserRepository extends RecordRepository
{
protected function getModelClass(): string
{
return User::class;
}
}
final class UserRepository extends RecordRepository
{
public function findById(int $id): ?User
{
return parent::find($id);
}
public function findByIdentifier(string $identifier): ?User
{
$key = $this->cache->generateKey($this->tableName, 'findByIdentifier', $identifier);
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached;
}
$user = User::query()->where('identifier', '=', $identifier)->first();
if ($user !== null) {
$this->cache->set($key, $user);
}
return $user;
}
public function findByEmail(string $email): ?User
{
$key = $this->cache->generateKey($this->tableName, 'findByEmail', $email);
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached;
}
$user = User::query()->where('email', '=', $email)->first();
if ($user !== null) {
$this->cache->set($key, $user);
}
return $user;
}
}
public function create(mixed $data): User
{
$user = new User();
$user->email = $data->email;
$user->save();
// Invalidate cache
$this->cache->invalidate($this->tableName);
return $user;
}
ForgeSqlOrm provides built-in query caching for performance optimization.
QueryCache is a simple in-memory cache for query results:
// Default TTL: 3600 seconds (1 hour)
$cache = new QueryCache(3600);
// Get from cache
$cached = $cache->get($key);
// Set in cache
$cache->set($key, $value);
// Forget specific key
$cache->forget($key);
// Invalidate table cache
$cache->invalidate('users');
// Clear all cache
$cache->clear();
$key = $cache->generateKey('users', 'findByIdentifier', $identifier);
// Generates: "users:findByIdentifier:value"
RecordRepository automatically uses QueryCache for find operations:
// Repository automatically caches find() results
$user = $repository->find(1); // Cached
$user = $repository->find(1); // Returns from cache
Cache is automatically invalidated on create/update/delete:
// Create/update/delete invalidates table cache
$repository->create($data); // Invalidates 'users' cache
$repository->update(1, $data); // Invalidates 'users' cache
$repository->delete(1); // Invalidates 'users' cache
ForgeSqlOrm provides full transaction support via QueryBuilder.
// Begin transaction
$this->builder->beginTransaction();
// Commit transaction
$this->builder->commit();
// Rollback transaction
$this->builder->rollback();
// Check if in transaction
$inTransaction = $this->builder->inTransaction();
try {
$this->builder->beginTransaction();
$id = $this->builder->table('example_table')
->insert(['name' => 'orm_transaction_commit']);
$this->builder->commit();
return ['status' => 'committed', 'inserted_id' => $id];
} catch (\Exception $e) {
$this->builder->rollback();
return ['status' => 'error', 'message' => $e->getMessage()];
}
// Count before transaction
$countBefore = $this->builder->table('example_table')
->where('name', '=', 'orm_transaction_rollback')
->count();
try {
$this->builder->beginTransaction();
$this->builder->table('example_table')
->insert(['name' => 'orm_transaction_rollback']);
throw new \Exception('Simulated error to trigger rollback');
$this->builder->commit();
} catch (\Exception $e) {
$this->builder->rollback();
// Count after rollback
$countAfter = $this->builder->table('example_table')
->where('name', '=', 'orm_transaction_rollback')
->count();
// Verify rollback worked
return [
'status' => 'rolled_back',
'count_before' => $countBefore,
'count_after' => $countAfter,
'rollback_worked' => $countAfter == $countBefore
];
}
ForgeSqlOrm supports raw SQL queries when you need full control over the query.
Execute raw SQL with parameter binding:
$results = $this->builder->raw(
"SELECT * FROM users WHERE status = :status",
[':status' => 'active']
);
Add raw WHERE clauses to query builder chain:
$results = $this->builder
->table('users')
->whereRaw('status = :status', [':status' => 'active'])
->get();
$results = $this->builder
->table('users')
->select('id', 'email', 'identifier')
->where('status', '=', 'active')
->whereRaw('identifier IS NOT NULL', [])
->orderBy('created_at', 'DESC')
->limit(10)
->get();
Security Note: Always use parameter binding with raw SQL to prevent SQL injection attacks. Never concatenate user input directly into SQL queries.
Comprehensive examples demonstrating ForgeSqlOrm usage.
User model with relationships, casting, and protected fields:
use App\Modules\ForgeSqlOrm\ORM\Attributes\{Table, Column, ProtectedFields};
use App\Modules\ForgeSqlOrm\ORM\Values\{Cast, Relate, RelationKind};
use App\Modules\ForgeSqlOrm\Traits\HasTimeStamps;
#[Table("users")]
#[ProtectedFields(['password'])]
class User extends Model
{
use HasTimeStamps;
use CanLoadRelations;
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $status;
#[Column(cast: Cast::STRING)]
public string $identifier;
#[Column(cast: Cast::STRING)]
public string $email;
#[Column(cast: Cast::STRING)]
public string $password; // Protected from serialization
#[Column(cast: Cast::JSON)]
public ?UserMetadataDto $metadata;
#[Relate(RelationKind::HasOne, Profile::class, "user_id")]
public function profile(): Relation
{
return self::describe(__FUNCTION__);
}
}
Profile model with traits and nullable fields:
use App\Modules\ForgeSqlOrm\Traits\{HasMetaData, HasTimeStamps};
#[Table("profiles")]
class Profile extends Model
{
use HasTimeStamps;
use HasMetaData;
#[Column(primary: true, cast: Cast::INT)]
public int $id;
#[Column(cast: Cast::INT)]
public int $user_id;
#[Column(cast: Cast::STRING)]
public string $first_name;
#[Column(cast: Cast::STRING)]
public ?string $last_name; // Nullable
#[Column(cast: Cast::STRING)]
public ?string $avatar; // Nullable
}
Post model with tenant scoping:
use App\Modules\ForgeMultiTenant\Attributes\TenantScoped;
use App\Modules\ForgeMultiTenant\Traits\TenantScopedTrait;
#[TenantScoped]
#[Table("posts")]
class Post extends Model
{
use HasTimeStamps;
use HasMetaData;
use TenantScopedTrait;
#[Column(primary: true, cast: Cast::STRING)]
public int $id;
#[Column(cast: Cast::STRING)]
public string $title;
#[Column(cast: Cast::STRING)]
public string $content;
#[Column(cast: Cast::STRING)]
public string $tenant_id;
}
Basic queries with where(), orderBy(), limit():
$results = $this->builder
->table('users')
->select('id', 'email', 'identifier')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->limit(10)
->get();
Finding by ID and querying with conditions:
// Find by ID
$user = User::query()->id(1)->first();
// Query with conditions
$activeUsers = User::query()
->where('status', '=', 'active')
->get();
// Eager load relationships
$users = User::query()
->with('profile')
->get();
Using repositories with caching:
// Find by ID (cached)
$user = $repository->findById(1);
// Find by identifier (cached)
$user = $repository->findByIdentifier('user-123');
// Find by email (cached)
$user = $repository->findByEmail('user@example.com');
// Create (invalidates cache)
$user = $repository->create($data);
Guidelines for using ForgeSqlOrm effectively.