ttpryg / content-engine
A framework-agnostic standalone CMS content engine library for PHP.
Requires
- php: >=8.1
- psr/event-dispatcher: ^1.0
- ttpryg/database-migrator: ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.53
- laravel/pint: ^1.20
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0
- rector/rector: ^2.6
Suggests
- ttpryg/event-dispatcher: Provides a lightweight PSR-14 event dispatcher implementation.
Provides
None
Conflicts
None
Replaces
None
README
ttpryg/content-engine is a framework-agnostic standalone PHP library for CMS content management (posts, pages, testimonials, FAQs), taxonomy (categories/tags), polymorphic multi-tenant scoping (tenant_type, tenant_id), automatic slug generation, metadata wrapping, and status lifecycle management.
🌟 Key Features
- Framework Agnostic: Compatible with any PHP 8.1+ project (Vanilla PHP, Slim, Laravel, Symfony, CodeIgniter).
- Polymorphic Multi-Tenant Scoping:
tenant_type&tenant_id: Generic scoping for stores (store), companies (company), blogs (blog), or organizations (findByTenant).author_id: Track author/user ownership fromauth-user(findByAuthorId).nullscoping: Global platform content managed by system admins (e.g. Terms of Service, Main Platform Blog).
- Multi-Content Support: Standardized handling for
post,page,testimonial,faq, or custom content types. - Improved Database Schema:
contentstable withtenant_type,tenant_id,author_id,summary(excerpt),sort_order,view_count,deleted_at(soft deletes), andmeta(JSON).categories&content_categorytables for Taxonomy (Categories & Tags).
- Strongly Typed Meta Wrappers:
TestimonialMeta: Company, Position, Rating, Avatar URL.SeoMeta: Meta Title, Meta Description, OG Image, Keywords.
- Automatic Slug Generator: Converts titles to clean URLs with collision resolution (e.g.
my-post-1,my-post-2), including soft-deleted entries sorestore()never hits a unique-key collision. Explicit duplicate slugs are rejected with a domain exception, and global slugs are protected by a dedicated unique index. - Domain Events:
ContentCreatedEvent,ContentPublishedEvent,ContentArchivedEvent,ContentDeletedEvent,ContentViewedEvent, dispatched through the PSR-14Psr\EventDispatcher\EventDispatcherInterfaceso any compliant dispatcher can be used. - Explicit Use Cases & DTOs: content create/publish/archive/delete/view actions live in
Application\UseCase, withCreateContentRequestreplacing long primitive argument lists;ContentServiceremains the convenience facade. - Tenant-Exact Reads:
findBySlug()always scopes to the exact tenant, or to global content when the tenant isnull, so a global lookup cannot leak another tenant's content. - Published-by-Default Reads:
getContentBySlug()only returnspublishedcontent. Passstatus: null(or an explicit status) for admin previews. - Security Hardening:
ORDER BYcolumn whitelist (unknown columns throwInvalidOrderByException), pagination clamped to 1–100 rows (DoS prevention), escapedLIKEsearches, validated table names, and parameterized PDO queries throughout. - Versioned Schema Migrations: PHP-based migrations backed by
ttpryg/database-migratorand acontent_engine_migrationstracker table — migrations run once, are recorded only after success, support rollback, and resume safely after failures. - Scalability Options:
deferViewCounts: trueskips the per-readUPDATE view_count(avoids row-lock contention under heavy traffic);ContentViewedEventprovides the hook for async buffering (Redis / queue).
🗄️ Database Schema
Run the migrations with DatabaseMigrator (see Database Migrations). The resulting schema includes the contents table (shown below) plus the categories and content_category (pivot) tables:
CREATE TABLE IF NOT EXISTS contents ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_type VARCHAR(50) NULL COMMENT 'Tipe tenant (store, company, blog)', tenant_id VARCHAR(100) NULL COMMENT 'ID tenant', author_id BIGINT UNSIGNED NULL COMMENT 'ID User (AuthUser)', type VARCHAR(30) NOT NULL COMMENT 'page, post, testimonial, faq', title VARCHAR(255) NOT NULL, slug VARCHAR(255) NULL, summary VARCHAR(500) NULL, body LONGTEXT NULL, meta JSON NULL, status ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft', sort_order INT NOT NULL DEFAULT 0, view_count BIGINT UNSIGNED NOT NULL DEFAULT 0, published_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, UNIQUE KEY uk_tenant_slug (tenant_type, tenant_id, type, slug), INDEX idx_tenant_content (tenant_type, tenant_id, type, status), INDEX idx_author_content (author_id, status), INDEX idx_type_status_published (type, status, published_at), INDEX idx_slug_type (slug, type), INDEX idx_sort (type, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
The
UNIQUE KEY uk_tenant_slugprevents slug collisions across tenants per content type, and doubles as the composite index for(tenant_type, tenant_id, type, slug)lookups.
🚀 Quick Usage Example
use PDO; use Ttpryg\ContentEngine\Application\DTO\CreateContentRequest; use Ttpryg\ContentEngine\Application\Service\ContentService; use Ttpryg\ContentEngine\Infrastructure\Persistence\Pdo\PdoContentRepository; use Ttpryg\ContentEngine\Utilities\NativeSlugGenerator; // 1. Initialize PDO & Services $pdo = new PDO("mysql:host=localhost;dbname=my_db", "root", "secret"); $contentRepo = new PdoContentRepository($pdo); $contentService = new ContentService($contentRepo, new NativeSlugGenerator); // 2. Create Global Platform Announcement (Admin) $globalPost = $contentService->createContent(new CreateContentRequest( title: 'Platform Maintenance Schedule', type: 'post', body: 'System will be updated on Sunday...', status: 'published' )); // 3. Create Store-Specific News (Owner of Store 101) $storePost = $contentService->createContent(new CreateContentRequest( title: 'Grand Opening Sale at Toko Sepatu Jaya!', type: 'post', body: 'Visit our store for 50% off...', status: 'published', authorId: 77, // User ID from auth-user tenantType: 'store', // Tenant Scope tenantId: '101' // Store ID )); // 4. Query Posts for Store 101 $storePosts = $contentRepo->findByTenant(tenantType: 'store', tenantId: '101', type: 'post');
🧱 Persistence Adapters
The module core (src/) depends only on repository ports. Concrete storage lives under infrastructure/:
Infrastructure\Persistence\Pdo\PdoContentRepositoryandPdoCategoryRepositoryfor production PDO storage (MySQL, MariaDB, SQLite).Infrastructure\Persistence\InMemory\InMemoryContentRepositoryandInMemoryCategoryRepositoryas fast fakes for tests and prototypes.
use Ttpryg\ContentEngine\Infrastructure\Persistence\InMemory\InMemoryContentRepository; use Ttpryg\ContentEngine\Infrastructure\Persistence\InMemory\InMemoryCategoryRepository; $contentRepository = new InMemoryContentRepository; $categoryRepository = new InMemoryCategoryRepository;
🔄 Database Migrations
Schema changes are managed by DatabaseMigrator, backed by ttpryg/database-migrator. Migrations live in database/migrations/ as dated PHP classes and are tracked in the content_engine_migrations table. Each migration runs once in filename order, is recorded only after success, and is retried on the next run if it fails.
use Ttpryg\ContentEngine\Infrastructure\Database\DatabaseMigrator; $migrator = new DatabaseMigrator($pdo); $migrator->migrate(); // create/upgrade the schema $migrator->rollback(); // revert the latest migration $migrator->appliedMigrations(); // full applied history
The baseline migration is driver-aware (SQLite and MySQL/MariaDB), creates the contents, categories, and content_category tables plus lookup indexes, and can be rolled back. A dedicated migration adds a generated global_slug_key and unique index so slugs for global content (nullable tenant columns) are also protected on both MySQL and SQLite.
Databases previously tracked by the SQL-based runner remain compatible: the legacy
001_add_unique_tenant_slugrecord is recognised through the new migration's alias.
⚡ High-Traffic View Counting
By default, every read via getContentBySlug() runs UPDATE contents SET view_count = view_count + 1, taking a row lock per read. Under heavy traffic, enable deferred counting:
use Ttpryg\ContentEngine\Application\Service\ContentService; use Ttpryg\ContentEngine\Utilities\NativeSlugGenerator; $contentService = new ContentService( contentRepository: $contentRepo, slugGenerator: new NativeSlugGenerator, eventDispatcher: $dispatcher, deferViewCounts: true // no UPDATE on read );
ContentViewedEvent is still dispatched on every read — subscribe a listener that buffers counts (e.g. in Redis or _modules/queue) and flushes them in batches. The default (deferViewCounts: false) preserves the original behaviour.
Public reads are restricted to published content by default. Pass status: null to include any status (for example in an authenticated admin preview), or pass status: 'draft' explicitly.
🛠️ Development
composer test # PHPUnit (unit + integration, in-memory SQLite; MySQL when CONTENT_ENGINE_MYSQL_DSN is set) composer analyse # PHPStan level 8 (src, infrastructure, migrations, tests) composer fix # Rector + Pint (auto-fix) composer fix:check # Rector dry-run + Pint test + PHPStan (CI gate)
📄 License
MIT License.