baukasten / orm
Simple ORM system for PHP.
Requires
- php: ^8.0
- ext-pdo: *
Requires (Dev)
- mikey179/vfsstream: 1.6.11
- mockery/mockery: 1.6.11
- phpunit/phpunit: ^9
This package is auto-updated.
Last update: 2026-01-08 11:48:14 UTC
README
A lightweight PHP ORM with a Spring Boot JPA-inspired repository pattern, featuring annotation-based entity mapping, magic finder methods, and automatic query generation.
Features
- Spring Boot JPA-like Repository Pattern - Familiar pattern with
#[Entity],#[Column], and#[EntityClass]annotations - Magic Finder Methods - Automatically generate queries from method names (e.g.,
findByNameAndEmail()) - Complete CRUD Operations - Built-in
findById(),save(),delete(), and more - Type-Safe Column Definitions -
ColumnTypeenum for type safety - Flexible Query Approaches - Query builder, raw SQL with hydration, or direct PDO access
- Relationship Management - OneToMany, ManyToOne, ManyToMany with lazy loading
- Query Builder - Fluent API for complex queries with raw SQL support
- Query Logging - Built-in query logging for debugging
Installation
composer require baukasten/orm
Quick Start
1. Define an Entity
use Baukasten\ORM\Annotation\{Column, Entity, PrimaryKey, Filterable, Lazy, ManyToOne, ManyToMany};
use Baukasten\ORM\ColumnType;
use Baukasten\ORM\LazyLoadable;
#[Entity(table: 'post')]
class Post
{
use LazyLoadable;
#[PrimaryKey(autoIncrement: true)]
#[Column(name: 'post_id', type: ColumnType::INTEGER)]
private ?int $post_id;
#[Column(name: 'title', type: ColumnType::TEXT)]
#[Filterable]
private string $title;
#[Column(name: 'permalink', type: ColumnType::TEXT)]
#[Filterable]
private string $permalink;
#[Column(name: 'content', type: ColumnType::TEXT, nullable: true)]
private ?string $content;
#[Column(name: 'status', type: ColumnType::TEXT, default: 'draft')]
#[Filterable]
private string $status;
#[Column(name: 'post_date', type: ColumnType::DATETIME)]
private string $post_date;
#[Lazy]
#[ManyToOne(targetEntity: Author::class)]
#[Column(name: 'author_id', type: ColumnType::INTEGER, nullable: true)]
private ?Author $author = null;
#[ManyToMany(targetEntity: PostTaxonomy::class, joinTable: "post__taxonomy_mapping", joinColumn: "post_id", inverseJoinColumn: "taxonomy_id")]
private ?array $taxonomies = null;
// Getters and setters
public function getId(): ?int { return $this->post_id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getPermalink(): string { return $this->permalink; }
public function setPermalink(string $permalink): void { $this->permalink = $permalink; }
public function getStatus(): string { return $this->status; }
public function setStatus(string $status): void { $this->status = $status; }
// ... more getters/setters
}
2. Create a Repository
use Baukasten\ORM\Annotation\EntityClass;
use Baukasten\ORM\Repository;
#[EntityClass(Post::class)]
class PostRepository extends Repository
{
// That's it! No constructor needed.
// CRUD methods and magic finders work automatically.
}
3. Use the Repository
use Baukasten\ORM\EntityManager;
// Initialize EntityManager
$em = EntityManager::fromPDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
// Create repository
$postRepo = new PostRepository();
// CRUD Operations
$post = new Post();
$post->setTitle('My First Blog Post');
$post->setPermalink('my-first-blog-post');
$post->setStatus('published');
$postId = $postRepo->save($post); // Insert
$post = $postRepo->findById(5); // Find by ID
$posts = $postRepo->findAll(); // Get all
$postRepo->delete($post); // Delete
Magic Finder Methods
Call methods that don't exist - they're automatically parsed into SQL queries!
// Simple equality
$posts = $postRepo->findByPermalink('my-first-post');
// SQL: SELECT * FROM post WHERE permalink = 'my-first-post'
// Multiple conditions with AND
$posts = $postRepo->findByTitleAndStatus('My Post', 'published');
// SQL: SELECT * FROM post WHERE title = 'My Post' AND status = 'published'
// Multiple conditions with OR
$posts = $postRepo->findByStatusOrPostDate('draft', '2024-01-01');
// SQL: SELECT * FROM post WHERE status = 'draft' OR post_date = '2024-01-01'
// Comparison operators
$posts = $postRepo->findWherePostDateIsGreaterThan('2024-01-01');
// SQL: SELECT * FROM post WHERE post_date > '2024-01-01'
$posts = $postRepo->findWherePostDateIsBefore('2024-12-31');
// SQL: SELECT * FROM post WHERE post_date < '2024-12-31'
// Complex combinations
$posts = $postRepo->findByStatusAndPostDateGreaterThan('published', '2024-01-01');
// SQL: SELECT * FROM post WHERE status = 'published' AND post_date > '2024-01-01'
Supported Operators:
IsGreaterThan,GreaterThan→>IsLessThan,LessThan→<IsGreaterThanOrEqual→>=IsLessThanOrEqual→<=IsEqual,Equals→=IsNotEqual,NotEqual→!=IsBefore,Before→<IsAfter,After→>Like→LIKE- No operator →
=
Custom Queries
For complex queries, you have three options:
Option 1: Query Builder (Recommended)
#[EntityClass(Post::class)]
class PostRepository extends Repository
{
public function findPublishedPosts(): array
{
return $this->em->queryBuilder()
->select($this->entity_class)
->where('status', 'published')
->orderBy('post_date', 'DESC')
->execute();
}
public function findRecentPosts(int $limit = 10): array
{
return $this->em->queryBuilder()
->select($this->entity_class)
->where('status', 'published')
->orderBy('post_date', 'DESC')
->limit($limit)
->execute();
}
}
Option 2: Raw SQL with Hydration
#[EntityClass(Post::class)]
class PostRepository extends Repository
{
public function findPostsAfterDate(string $date): array
{
return $this->selectAll(
'SELECT * FROM post WHERE post_date >= :date ORDER BY post_date DESC',
['date' => $date]
);
}
public function findMostRecentPost(): ?object
{
return $this->selectOne(
'SELECT * FROM post WHERE status = "published" ORDER BY post_date DESC LIMIT 1'
);
}
public function searchPosts(string $titlePattern, string $minDate, string $status): array
{
return $this->selectAll(
'SELECT * FROM post
WHERE title LIKE :titlePattern
AND post_date >= :minDate
AND status = :status',
[
'titlePattern' => $titlePattern,
'minDate' => $minDate,
'status' => $status
]
);
}
}
Option 3: Direct PDO Access
public function getPostStatistics(): array
{
$sql = 'SELECT status, COUNT(*) as count, AVG(views) as avg_views
FROM post
GROUP BY status';
$stmt = $this->em->getPdo()->query($sql);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
CRUD Operations
All repositories include these methods:
// Create / Update
$postRepo->save($post); // Insert if new, update if exists
$postId = $postRepo->insert($post); // Explicit insert
$postRepo->update($post); // Explicit update
// Read
$post = $postRepo->findById(5); // Find by ID
$post = $postRepo->findOneBy(['permalink' => 'my-post']); // Find one
$posts = $postRepo->findBy(['status' => 'published']); // Find all matching
$posts = $postRepo->findAll(); // Get all
// Delete
$postRepo->delete($post); // Delete entity
$postRepo->deleteById(5); // Delete by ID
// Utilities
$count = $postRepo->count(); // Count all
$exists = $postRepo->existsById(5); // Check existence
ColumnType Enum
Use type-safe column types:
ColumnType::STRING // VARCHAR, TEXT
ColumnType::INT // INTEGER
ColumnType::FLOAT // FLOAT
ColumnType::DATETIME // DATETIME
ColumnType::DATE // DATE
ColumnType::BOOLEAN // BOOLEAN
ColumnType::JSON // JSON
ColumnType::TIMESTAMP // TIMESTAMP
// ... and more
Examples
- Repository Pattern:
examples/repository-pattern-example.php- Complete demonstration - Many-to-Many:
examples/many-to-many-example.php- Relationship example - Query Builder:
examples/query-builder-operations.php- Advanced queries - Encapsulation:
examples/encapsulated-entity-example.php- Best practices
Documentation
- Repository Pattern Guide - Complete guide to Spring Boot JPA-like repositories
- Many-to-Many Relationships - Guide to many-to-many relationships with lazy loading
- Project Instructions (CLAUDE.md) - Comprehensive architecture and patterns
Testing
Unit tests are available for all classes. See the tests/README.md file for more information on running tests and test coverage.
./vendor/bin/phpunit
# Run specific test suite
./vendor/bin/phpunit tests/Unit/MagicMethodFinderTest.php
# With Docker wrapper (if direct execution doesn't work)
/bin/bash ./run-phpunit.sh
Requirements
- PHP 8.0 or higher
- PDO extension
Key Concepts
Entities
Entities are simple PHP classes with attributes:
#[Entity('table_name')]- Marks a class as an entity#[Column("col_name", type: ColumnType::TYPE)]- Maps properties to columns#[PrimaryKey(autoIncrement: true)]- Defines the primary key- Private properties with getters/setters for encapsulation
Repositories
Repositories handle database operations:
- Extend
Repositorybase class - Use
#[EntityClass(Entity::class)]to specify the entity - Inherit CRUD operations automatically
- Magic finders parse method names into SQL
- Custom queries using query builder or raw SQL with hydration helpers
Magic Finders
Method names are automatically parsed:
findBy{Property}- Simple equalityfindBy{Property1}And{Property2}- Multiple conditions with ANDfindBy{Property1}Or{Property2}- Multiple conditions with ORfindWhere{Property}IsGreaterThan- Comparison operators- Property names in CamelCase, converted to snake_case automatically
Method Resolution Order:
- Existing methods in your repository - Custom implementations take precedence
- Parent class methods - Built-in CRUD methods (findById, findAll, save, etc.)
- Magic finders - Automatically parsed if method doesn't exist
#[EntityClass(Post::class)]
class PostRepository extends Repository
{
// Custom implementation - called instead of magic finder
protected function findByPermalink(string $permalink): array
{
return $this->selectAll(
'SELECT * FROM post WHERE LOWER(permalink) = LOWER(:permalink)',
['permalink' => $permalink]
);
}
// Magic finders still work for undefined methods:
// $repo->findByTitle($title) - automatically generates SQL
// $repo->findByPostDateGreaterThan($date) - automatically generates SQL
}
// Meanwhile, parent methods always work:
$post = $repo->findById(5); // From Repository parent class
$posts = $repo->findAll(); // From Repository parent class
License
MIT License