Propel
Propel is "a fast, open-source ORM for PHP" developed as an Object-Relational Mapping library for SQL databases compatible with PHP 8. It enables simple data storage and retrieval through a simple API, implementing important ORM layer concepts including ActiveRecord patterns, validators, behaviors, table inheritance, reverse engineering of existing databases, nested sets, nested transactions, lazy loading, and LOBs. It supports MySQL, PostgreSQL, SQLite, MSSQL, and Oracle for compatibility, with a design that emphasizes flexibility and extensibility.
GitHub Overview
propelorm/Propel2
Propel2 is an open-source high-performance Object-Relational Mapping (ORM) for modern PHP
Topics
Star History
Library
Propel
Overview
Propel is "a fast, open-source ORM for PHP" developed as an Object-Relational Mapping library for SQL databases compatible with PHP 8. It enables simple data storage and retrieval through a simple API, implementing important ORM layer concepts including ActiveRecord patterns, validators, behaviors, table inheritance, reverse engineering of existing databases, nested sets, nested transactions, lazy loading, and LOBs. It supports MySQL, PostgreSQL, SQLite, MSSQL, and Oracle for compatibility, with a design that emphasizes flexibility and extensibility.
Details
Propel 2025 edition is a historically significant ORM solution in the PHP ecosystem, particularly having been adopted as the primary ORM during the Symfony 1.x era. It uses a code generation approach for schema-driven class generation, providing high-performance and compact ActiveRecord patterns. Designed to allow developers to maintain control over their code, extensibility is at the center of Propel's design. It enables direct access to all database features, stepping aside when custom queries or ultra-optimized transactions are needed.
Key Features
- Mature ORM Functionality: Complete with ActiveRecord, validators, behaviors, table inheritance
- Schema-Driven Development: Automatic code generation from XML schemas
- High Performance: Optimized query generation and caching capabilities
- Flexibility and Extensibility: Easy customization and extension
- Multi-Database Support: MySQL, PostgreSQL, SQLite, MSSQL, Oracle support
- Reverse Engineering: Schema generation from existing databases
Pros and Cons
Pros
- Mature ORM functionality sufficient for enterprise application development
- Schema-driven development clarifies database design and improves maintainability
- XML schema metadata management makes documentation easy
- Code generation provides fast and optimized code
- Easy portability between multiple database vendors
- Rich documentation and community support
Cons
- Doctrine ORM is mainstream in modern Symfony applications
- Build step required due to code generation approach
- Annotation-based approach is outdated compared to Doctrine
- Limited utilization of modern PHP features (namespaces, type hints, etc.)
- Few adoption cases in new projects
- Limited learning resources compared to Eloquent and Doctrine
Reference Pages
Code Examples
Setup
# Install with Composer
composer require propel/propel
# Initialize project
vendor/bin/propel init
<!-- schema.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<database name="blog" defaultIdMethod="native"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://xsd.propelorm.org/1.7/database.xsd"
namespace="Blog\Model">
<table name="user" phpName="User">
<column name="id" type="INTEGER" required="true" primaryKey="true" autoIncrement="true"/>
<column name="username" type="VARCHAR" size="100" required="true"/>
<column name="email" type="VARCHAR" size="255" required="true"/>
<column name="password_hash" type="VARCHAR" size="255" required="true"/>
<column name="first_name" type="VARCHAR" size="100"/>
<column name="last_name" type="VARCHAR" size="100"/>
<column name="is_active" type="BOOLEAN" defaultValue="true"/>
<column name="created_at" type="TIMESTAMP"/>
<column name="updated_at" type="TIMESTAMP"/>
<unique>
<unique-column name="username"/>
<unique-column name="email"/>
</unique>
<behavior name="timestampable"/>
</table>
<table name="post" phpName="Post">
<column name="id" type="INTEGER" required="true" primaryKey="true" autoIncrement="true"/>
<column name="title" type="VARCHAR" size="200" required="true"/>
<column name="slug" type="VARCHAR" size="200" required="true"/>
<column name="content" type="LONGVARCHAR"/>
<column name="excerpt" type="VARCHAR" size="500"/>
<column name="is_published" type="BOOLEAN" defaultValue="false"/>
<column name="published_at" type="TIMESTAMP"/>
<column name="author_id" type="INTEGER" required="true"/>
<column name="created_at" type="TIMESTAMP"/>
<column name="updated_at" type="TIMESTAMP"/>
<foreign-key foreignTable="user" onDelete="CASCADE">
<reference local="author_id" foreign="id"/>
</foreign-key>
<unique>
<unique-column name="slug"/>
</unique>
<behavior name="timestampable"/>
<behavior name="sluggable">
<parameter name="slug_column" value="slug"/>
<parameter name="slug_pattern" value="{Title}"/>
</behavior>
</table>
<table name="comment" phpName="Comment">
<column name="id" type="INTEGER" required="true" primaryKey="true" autoIncrement="true"/>
<column name="post_id" type="INTEGER" required="true"/>
<column name="author_name" type="VARCHAR" size="100" required="true"/>
<column name="author_email" type="VARCHAR" size="255" required="true"/>
<column name="content" type="LONGVARCHAR" required="true"/>
<column name="is_approved" type="BOOLEAN" defaultValue="false"/>
<column name="created_at" type="TIMESTAMP"/>
<foreign-key foreignTable="post" onDelete="CASCADE">
<reference local="post_id" foreign="id"/>
</foreign-key>
<behavior name="timestampable"/>
</table>
</database>
Basic Usage
<?php
// Propel initialization
require_once 'vendor/autoload.php';
require_once 'generated-conf/config.php';
use Blog\Model\User;
use Blog\Model\UserQuery;
use Blog\Model\Post;
use Blog\Model\PostQuery;
use Propel\Runtime\Propel;
// Basic CRUD operations
class UserService
{
// Create user
public function createUser(string $username, string $email, string $password, string $firstName = null, string $lastName = null): User
{
$user = new User();
$user->setUsername($username);
$user->setEmail($email);
$user->setPasswordHash(password_hash($password, PASSWORD_BCRYPT));
$user->setFirstName($firstName);
$user->setLastName($lastName);
$user->setIsActive(true);
$user->save();
return $user;
}
// Find user
public function findUserByEmail(string $email): ?User
{
return UserQuery::create()
->filterByEmail($email)
->findOne();
}
public function findUserByUsername(string $username): ?User
{
return UserQuery::create()
->filterByUsername($username)
->findOne();
}
// Get all users
public function getAllUsers(): array
{
return UserQuery::create()
->filterByIsActive(true)
->orderByCreatedAt('desc')
->find()
->toArray();
}
// Update user
public function updateUser(int $userId, array $data): bool
{
$user = UserQuery::create()->findPk($userId);
if (!$user) {
return false;
}
if (isset($data['first_name'])) {
$user->setFirstName($data['first_name']);
}
if (isset($data['last_name'])) {
$user->setLastName($data['last_name']);
}
if (isset($data['email'])) {
$user->setEmail($data['email']);
}
$user->save();
return true;
}
// Delete user (soft delete)
public function deactivateUser(int $userId): bool
{
$user = UserQuery::create()->findPk($userId);
if (!$user) {
return false;
}
$user->setIsActive(false);
$user->save();
return true;
}
}
Query Execution
<?php
use Blog\Model\Post;
use Blog\Model\PostQuery;
use Blog\Model\UserQuery;
use Blog\Model\CommentQuery;
use Propel\Runtime\ActiveQuery\Criteria;
class PostService
{
// Create post
public function createPost(int $authorId, string $title, string $content, string $excerpt = null): Post
{
$post = new Post();
$post->setAuthorId($authorId);
$post->setTitle($title);
$post->setContent($content);
$post->setExcerpt($excerpt ?: substr(strip_tags($content), 0, 200));
$post->setIsPublished(false);
$post->save();
return $post;
}
// Get published posts
public function getPublishedPosts(int $limit = 10, int $offset = 0): array
{
return PostQuery::create()
->filterByIsPublished(true)
->joinWithUser()
->orderByPublishedAt('desc')
->limit($limit)
->offset($offset)
->find()
->toArray();
}
// Search posts
public function searchPosts(string $query, bool $publishedOnly = true): array
{
$postQuery = PostQuery::create()
->where('Post.Title LIKE ?', "%{$query}%")
->_or()
->where('Post.Content LIKE ?', "%{$query}%")
->joinWithUser();
if ($publishedOnly) {
$postQuery->filterByIsPublished(true);
}
return $postQuery
->orderByCreatedAt('desc')
->find()
->toArray();
}
// Find posts by multiple criteria
public function findPostsByFilters(array $filters): array
{
$query = PostQuery::create();
if (isset($filters['author_id'])) {
$query->filterByAuthorId($filters['author_id']);
}
if (isset($filters['published'])) {
$query->filterByIsPublished($filters['published']);
}
if (isset($filters['date_from'])) {
$query->filterByCreatedAt(['min' => $filters['date_from']]);
}
if (isset($filters['date_to'])) {
$query->filterByCreatedAt(['max' => $filters['date_to']]);
}
if (isset($filters['title_contains'])) {
$query->filterByTitle("%{$filters['title_contains']}%", Criteria::LIKE);
}
return $query
->joinWithUser()
->orderByCreatedAt('desc')
->find()
->toArray();
}
// Get post count by user
public function getPostCountByUser(int $userId): int
{
return PostQuery::create()
->filterByAuthorId($userId)
->filterByIsPublished(true)
->count();
}
// Popular posts (by comment count)
public function getPopularPosts(int $limit = 5): array
{
return PostQuery::create()
->joinWithComment()
->filterByIsPublished(true)
->groupById()
->withColumn('COUNT(Comment.Id)', 'CommentCount')
->orderBy('CommentCount', 'desc')
->limit($limit)
->find()
->toArray();
}
}
Data Operations
<?php
use Blog\Model\Comment;
use Blog\Model\CommentQuery;
use Propel\Runtime\Propel;
use Propel\Runtime\Exception\PropelException;
class BlogManagementService
{
// Publish post with transaction
public function publishPost(int $postId): bool
{
$con = Propel::getWriteConnection();
$con->beginTransaction();
try {
$post = PostQuery::create()->findPk($postId, $con);
if (!$post) {
throw new \Exception("Post not found");
}
$post->setIsPublished(true);
$post->setPublishedAt(new \DateTime());
$post->save($con);
// Update user statistics (example)
$user = $post->getUser();
// Update user statistics...
$con->commit();
return true;
} catch (\Exception $e) {
$con->rollback();
throw $e;
}
}
// Bulk operations
public function bulkApproveComments(array $commentIds): int
{
$con = Propel::getWriteConnection();
$con->beginTransaction();
try {
$approvedCount = CommentQuery::create()
->filterById($commentIds)
->update(['IsApproved' => true], $con);
$con->commit();
return $approvedCount;
} catch (\Exception $e) {
$con->rollback();
throw $e;
}
}
// Get statistics
public function getBlogStatistics(): array
{
$stats = [];
// User statistics
$stats['total_users'] = UserQuery::create()->count();
$stats['active_users'] = UserQuery::create()->filterByIsActive(true)->count();
// Post statistics
$stats['total_posts'] = PostQuery::create()->count();
$stats['published_posts'] = PostQuery::create()->filterByIsPublished(true)->count();
$stats['draft_posts'] = PostQuery::create()->filterByIsPublished(false)->count();
// Comment statistics
$stats['total_comments'] = CommentQuery::create()->count();
$stats['approved_comments'] = CommentQuery::create()->filterByIsApproved(true)->count();
$stats['pending_comments'] = CommentQuery::create()->filterByIsApproved(false)->count();
// Recent posts
$stats['recent_posts'] = PostQuery::create()
->filterByIsPublished(true)
->orderByPublishedAt('desc')
->limit(5)
->find()
->toArray();
return $stats;
}
// Data cleanup
public function cleanupOldData(int $daysOld = 365): array
{
$cutoffDate = new \DateTime("-{$daysOld} days");
$results = [];
// Delete old comments
$deletedComments = CommentQuery::create()
->filterByCreatedAt(['max' => $cutoffDate])
->filterByIsApproved(false)
->delete();
$results['deleted_comments'] = $deletedComments;
// Delete old drafts
$deletedDrafts = PostQuery::create()
->filterByCreatedAt(['max' => $cutoffDate])
->filterByIsPublished(false)
->delete();
$results['deleted_drafts'] = $deletedDrafts;
return $results;
}
}
Configuration and Customization
<?php
// propel.yml configuration file
// generator:
// default_connection: blog
// connections: ['blog']
//
// database:
// connections:
// blog:
// adapter: mysql
// dsn: mysql:host=localhost;dbname=blog;charset=utf8mb4
// user: blog_user
// password: blog_password
// settings:
// charset: utf8mb4
// queries:
// utf8: "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci, COLLATION_CONNECTION = utf8mb4_unicode_ci, COLLATION_DATABASE = utf8mb4_unicode_ci, COLLATION_SERVER = utf8mb4_unicode_ci"
// Custom behavior
use Propel\Generator\Behavior\BehaviorInterface;
class TimestampableBehavior implements BehaviorInterface
{
// Custom behavior implementation
}
// Custom validator
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;
class UserValidator
{
public function validateUser(User $user): array
{
$validator = Validation::createValidator();
$violations = [];
// Username validation
$usernameViolations = $validator->validate($user->getUsername(), [
new Assert\NotBlank(),
new Assert\Length(['min' => 3, 'max' => 50]),
new Assert\Regex(['pattern' => '/^[a-zA-Z0-9_]+$/'])
]);
// Email validation
$emailViolations = $validator->validate($user->getEmail(), [
new Assert\NotBlank(),
new Assert\Email()
]);
// Duplicate check
if (UserQuery::create()->filterByUsername($user->getUsername())->filterById($user->getId(), Criteria::NOT_EQUAL)->count() > 0) {
$violations[] = 'Username already in use';
}
if (UserQuery::create()->filterByEmail($user->getEmail())->filterById($user->getId(), Criteria::NOT_EQUAL)->count() > 0) {
$violations[] = 'Email already in use';
}
return array_merge(
array_map(fn($v) => $v->getMessage(), iterator_to_array($usernameViolations)),
array_map(fn($v) => $v->getMessage(), iterator_to_array($emailViolations)),
$violations
);
}
}
Error Handling
<?php
use Propel\Runtime\Exception\PropelException;
use Psr\Log\LoggerInterface;
class SafeBlogService
{
private LoggerInterface $logger;
private UserValidator $userValidator;
public function __construct(LoggerInterface $logger, UserValidator $userValidator)
{
$this->logger = $logger;
$this->userValidator = $userValidator;
}
public function createUserSafely(array $userData): array
{
try {
$user = new User();
$user->setUsername($userData['username'] ?? '');
$user->setEmail($userData['email'] ?? '');
$user->setFirstName($userData['first_name'] ?? null);
$user->setLastName($userData['last_name'] ?? null);
// Validation
$errors = $this->userValidator->validateUser($user);
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
// Password hashing
if (empty($userData['password'])) {
return ['success' => false, 'errors' => ['Password is required']];
}
$user->setPasswordHash(password_hash($userData['password'], PASSWORD_BCRYPT));
$user->save();
$this->logger->info('User created', ['user_id' => $user->getId()]);
return [
'success' => true,
'user' => $user->toArray(),
'message' => 'User created successfully'
];
} catch (PropelException $e) {
$this->logger->error('Database error during user creation', [
'error' => $e->getMessage(),
'user_data' => $userData
]);
return [
'success' => false,
'errors' => ['Database error occurred']
];
} catch (\Exception $e) {
$this->logger->error('Unexpected error during user creation', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return [
'success' => false,
'errors' => ['System error occurred']
];
}
}
public function updatePostSafely(int $postId, array $updateData, int $userId): array
{
try {
$post = PostQuery::create()->findPk($postId);
if (!$post) {
return ['success' => false, 'errors' => ['Post not found']];
}
// Permission check
if ($post->getAuthorId() !== $userId) {
$this->logger->warning('Unauthorized post edit attempt', [
'post_id' => $postId,
'user_id' => $userId,
'actual_author_id' => $post->getAuthorId()
]);
return ['success' => false, 'errors' => ['No permission to edit this post']];
}
// Apply updates
if (isset($updateData['title'])) {
$post->setTitle($updateData['title']);
}
if (isset($updateData['content'])) {
$post->setContent($updateData['content']);
}
if (isset($updateData['excerpt'])) {
$post->setExcerpt($updateData['excerpt']);
}
$post->save();
return [
'success' => true,
'post' => $post->toArray(),
'message' => 'Post updated successfully'
];
} catch (PropelException $e) {
$this->logger->error('Database error during post update', [
'post_id' => $postId,
'error' => $e->getMessage()
]);
return [
'success' => false,
'errors' => ['Database error occurred']
];
}
}
}