Propel
Propelは「高速でオープンソースのPHP ORM」として開発された、PHP8対応のSQLデータベース用Object-Relational Mappingライブラリです。シンプルAPIでデータの保存や取得を可能にし、ActiveRecordパターン、バリデーター、ビヘイビア、テーブル継承、既存データベースのリバースエンジニアリング、ネステッドセット、ネステッドトランザクション、レイジーローディング、LOBなど成熟したORMレイヤーの重要な概念を実装。互換性がありMySQL、PostgreSQL、SQLite、MSSQL、Oracleをサポートし、柔軟性と拡張性を重視した設計です。
GitHub概要
propelorm/Propel2
Propel2 is an open-source high-performance Object-Relational Mapping (ORM) for modern PHP
トピックス
スター履歴
ライブラリ
Propel
概要
Propelは「高速でオープンソースのPHP ORM」として開発された、PHP8対応のSQLデータベース用Object-Relational Mappingライブラリです。シンプルAPIでデータの保存や取得を可能にし、ActiveRecordパターン、バリデーター、ビヘイビア、テーブル継承、既存データベースのリバースエンジニアリング、ネステッドセット、ネステッドトランザクション、レイジーローディング、LOBなど成熟したORMレイヤーの重要な概念を実装。互換性がありMySQL、PostgreSQL、SQLite、MSSQL、Oracleをサポートし、柔軟性と拡張性を重視した設計です。
詳細
Propel 2025年版はPHPエコシステムにおける歴史あるORMソリューションであり、特にSymfony 1.x時代において主要なORMとして採用されていました。コード生成アプローチでスキーマ駆動のクラス生成を行い、高性能でコンパクトなActiveRecordパターンを提供。開発者がコードの制御を維持できるように設計され、拡張性がPropel設計の中心にあります。データベースの全機能への直接アクセスを可能にし、カスタムクエリや超最適化されたトランザクションが必要な場合にPropelが道を譲ります。
主な特徴
- 成熟したORM機能: ActiveRecord、バリデーター、ビヘイビア、テーブル継承など全備
- スキーマ駆動開発: XMLスキーマからの自動コード生成
- 高パフォーマンス: 最適化されたクエリ生成とキャッシュ機能
- 柔軟性と拡張性: カスタマイズと拡張が容易
- マルチデータベース対応: MySQL、PostgreSQL、SQLite、MSSQL、Oracleサポート
- リバースエンジニアリング: 既存データベースからのスキーマ生成
メリット・デメリット
メリット
- 成熟したORM機能で企業アプリケーション開発に十分対応
- スキーマ駆動開発でデータベース設計が明確になり保守性向上
- XMLスキーマでのメタデータ管理でドキュメント化が容易
- コード生成により高速で最適化されたコードを提供
- 複数データベースベンダー間の移植が容易
- 豊富なドキュメントとコミュニティサポート
デメリット
- 現代のSymfonyアプリケーションではDoctrine ORMが主流
- コード生成アプローチのためビルドステップが必要
- Doctrineと比較してアノテーションベースのアプローチが古い
- 現代的なPHP機能(名前空間、型ヒントなど)の活用が限定的
- 新しいプロジェクトでの採用事例が少ない
- EloquentやDoctrineと比較して学習リソースが限定的
参考ページ
書き方の例
セットアップ
# Composerでインストール
composer require propel/propel
# プロジェクト初期化
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>
基本的な使い方
<?php
// Propel初期化
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;
// 基本的なCRUD操作
class UserService
{
// ユーザー作成
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;
}
// ユーザー検索
public function findUserByEmail(string $email): ?User
{
return UserQuery::create()
->filterByEmail($email)
->findOne();
}
public function findUserByUsername(string $username): ?User
{
return UserQuery::create()
->filterByUsername($username)
->findOne();
}
// 全ユーザー取得
public function getAllUsers(): array
{
return UserQuery::create()
->filterByIsActive(true)
->orderByCreatedAt('desc')
->find()
->toArray();
}
// ユーザー更新
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;
}
// ユーザー削除(論理削除)
public function deactivateUser(int $userId): bool
{
$user = UserQuery::create()->findPk($userId);
if (!$user) {
return false;
}
$user->setIsActive(false);
$user->save();
return true;
}
}
クエリ実行
<?php
use Blog\Model\Post;
use Blog\Model\PostQuery;
use Blog\Model\UserQuery;
use Blog\Model\CommentQuery;
use Propel\Runtime\ActiveQuery\Criteria;
class PostService
{
// 投稿作成
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;
}
// 公開投稿取得
public function getPublishedPosts(int $limit = 10, int $offset = 0): array
{
return PostQuery::create()
->filterByIsPublished(true)
->joinWithUser()
->orderByPublishedAt('desc')
->limit($limit)
->offset($offset)
->find()
->toArray();
}
// 投稿検索
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();
}
// 複数条件での検索
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();
}
// ユーザーの投稿数取得
public function getPostCountByUser(int $userId): int
{
return PostQuery::create()
->filterByAuthorId($userId)
->filterByIsPublished(true)
->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();
}
}
データ操作
<?php
use Blog\Model\Comment;
use Blog\Model\CommentQuery;
use Propel\Runtime\Propel;
use Propel\Runtime\Exception\PropelException;
class BlogManagementService
{
// トランザクションを使用した投稿公開
public function publishPost(int $postId): bool
{
$con = Propel::getWriteConnection();
$con->beginTransaction();
try {
$post = PostQuery::create()->findPk($postId, $con);
if (!$post) {
throw new \Exception("投稿が見つかりません");
}
$post->setIsPublished(true);
$post->setPublishedAt(new \DateTime());
$post->save($con);
// ユーザーの投稿数を更新(例)
$user = $post->getUser();
// ユーザーの統計情報更新等...
$con->commit();
return true;
} catch (\Exception $e) {
$con->rollback();
throw $e;
}
}
// バルク操作
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;
}
}
// 統計情報取得
public function getBlogStatistics(): array
{
$stats = [];
// ユーザー統計
$stats['total_users'] = UserQuery::create()->count();
$stats['active_users'] = UserQuery::create()->filterByIsActive(true)->count();
// 投稿統計
$stats['total_posts'] = PostQuery::create()->count();
$stats['published_posts'] = PostQuery::create()->filterByIsPublished(true)->count();
$stats['draft_posts'] = PostQuery::create()->filterByIsPublished(false)->count();
// コメント統計
$stats['total_comments'] = CommentQuery::create()->count();
$stats['approved_comments'] = CommentQuery::create()->filterByIsApproved(true)->count();
$stats['pending_comments'] = CommentQuery::create()->filterByIsApproved(false)->count();
// 最近の投稿
$stats['recent_posts'] = PostQuery::create()
->filterByIsPublished(true)
->orderByPublishedAt('desc')
->limit(5)
->find()
->toArray();
return $stats;
}
// データクリーンアップ
public function cleanupOldData(int $daysOld = 365): array
{
$cutoffDate = new \DateTime("-{$daysOld} days");
$results = [];
// 古いコメントを削除
$deletedComments = CommentQuery::create()
->filterByCreatedAt(['max' => $cutoffDate])
->filterByIsApproved(false)
->delete();
$results['deleted_comments'] = $deletedComments;
// 古い下書きを削除
$deletedDrafts = PostQuery::create()
->filterByCreatedAt(['max' => $cutoffDate])
->filterByIsPublished(false)
->delete();
$results['deleted_drafts'] = $deletedDrafts;
return $results;
}
}
設定とカスタマイズ
<?php
// propel.yml 設定ファイル
// 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"
// カスタムビヘイビア
use Propel\Generator\Behavior\BehaviorInterface;
class TimestampableBehavior implements BehaviorInterface
{
// カスタムビヘイビアの実装
}
// カスタムバリデーター
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;
class UserValidator
{
public function validateUser(User $user): array
{
$validator = Validation::createValidator();
$violations = [];
// ユーザー名検証
$usernameViolations = $validator->validate($user->getUsername(), [
new Assert\NotBlank(),
new Assert\Length(['min' => 3, 'max' => 50]),
new Assert\Regex(['pattern' => '/^[a-zA-Z0-9_]+$/'])
]);
// メール検証
$emailViolations = $validator->validate($user->getEmail(), [
new Assert\NotBlank(),
new Assert\Email()
]);
// 重複チェック
if (UserQuery::create()->filterByUsername($user->getUsername())->filterById($user->getId(), Criteria::NOT_EQUAL)->count() > 0) {
$violations[] = 'ユーザー名が既に使用されています';
}
if (UserQuery::create()->filterByEmail($user->getEmail())->filterById($user->getId(), Criteria::NOT_EQUAL)->count() > 0) {
$violations[] = 'メールアドレスが既に使用されています';
}
return array_merge(
array_map(fn($v) => $v->getMessage(), iterator_to_array($usernameViolations)),
array_map(fn($v) => $v->getMessage(), iterator_to_array($emailViolations)),
$violations
);
}
}
エラーハンドリング
<?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);
// バリデーション
$errors = $this->userValidator->validateUser($user);
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
// パスワードハッシュ化
if (empty($userData['password'])) {
return ['success' => false, 'errors' => ['パスワードが必要です']];
}
$user->setPasswordHash(password_hash($userData['password'], PASSWORD_BCRYPT));
$user->save();
$this->logger->info('ユーザーが作成されました', ['user_id' => $user->getId()]);
return [
'success' => true,
'user' => $user->toArray(),
'message' => 'ユーザーが正常に作成されました'
];
} catch (PropelException $e) {
$this->logger->error('ユーザー作成中にデータベースエラーが発生', [
'error' => $e->getMessage(),
'user_data' => $userData
]);
return [
'success' => false,
'errors' => ['データベースエラーが発生しました']
];
} catch (\Exception $e) {
$this->logger->error('ユーザー作成中に予期せぬエラーが発生', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return [
'success' => false,
'errors' => ['システムエラーが発生しました']
];
}
}
public function updatePostSafely(int $postId, array $updateData, int $userId): array
{
try {
$post = PostQuery::create()->findPk($postId);
if (!$post) {
return ['success' => false, 'errors' => ['投稿が見つかりません']];
}
// 権限チェック
if ($post->getAuthorId() !== $userId) {
$this->logger->warning('不正な投稿編集試行', [
'post_id' => $postId,
'user_id' => $userId,
'actual_author_id' => $post->getAuthorId()
]);
return ['success' => false, 'errors' => ['この投稿を編集する権限がありません']];
}
// 更新データ適用
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' => '投稿が正常に更新されました'
];
} catch (PropelException $e) {
$this->logger->error('投稿更新中にデータベースエラーが発生', [
'post_id' => $postId,
'error' => $e->getMessage()
]);
return [
'success' => false,
'errors' => ['データベースエラーが発生しました']
];
}
}
}