ttpryg / comment-engine
A framework-agnostic standalone comment engine library for PHP.
Requires
- php: >=8.1
- ext-mbstring: *
- psr/event-dispatcher: ^1.0
- ttpryg/database-migrator: ^1.0
Requires (Dev)
- laravel/pint: ^1.20
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0
- rector/rector: ^2.6
Suggests
- ext-pdo: Required for the PDO repository and database migrations.
- ext-pdo_mysql: Required to use the PDO repository with MySQL or MariaDB.
- ext-pdo_sqlite: Required to use the PDO repository with SQLite.
- ttpryg/event-dispatcher: Provides a lightweight PSR-14 event dispatcher implementation.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 01:36:35 UTC
README
ttpryg/comment-engine is a framework-agnostic, PSR-14 native PHP library for threaded comments with moderation, polymorphic commentable resources, optional author attribution, optional tenant scoping, soft deletes, and a database schema managed by ttpryg/database-migrator.
🌟 Key Features
- Framework Agnostic: Works in any PHP 8.1+ project (Vanilla PHP, Slim, Laravel, Symfony, CodeIgniter).
- PSR-14 Events:
CommentCreatedEvent,CommentUpdatedEvent,CommentDeletedEvent,CommentApprovedEvent,CommentRejectedEvent, andCommentMarkedAsSpamEventare dispatched throughPsr\EventDispatcher\EventDispatcherInterface, so any compliant dispatcher can be used. - Threaded Comments: Optional
parentIdfor replies; replies must reference an existing parent on the same commentable resource. - Moderation Lifecycle: Comments start as
pendingand can beapproved,rejected, or marked asspam. - Polymorphic Targets:
commentable_type+commentable_idattach comments to content, products, or any other resource without schema changes. - Author Ownership:
author_idlinks comments to users fromauth-user; guest comments usenull. - Optional Tenancy: a nullable
tenant_idscopes every read and write. Pass a tenant to isolate comments, ornullfor the previous unscoped behaviour. - Explicit Use Cases: create, update, delete, and moderate actions live in
Application\UseCase, withCommentServiceas a convenience facade. - Security Hardening: order-by whitelist, clamped pagination (1–100 rows), validated table names, and parameterized PDO queries throughout.
- Persistence Adapters: PDO for production (MySQL/MariaDB/SQLite) and an in-memory repository for tests and prototypes.
📦 Installation
composer require ttpryg/comment-engine
Requirements: PHP 8.1 or higher with the
mbstringextension. The bundled PDO adapter also needspdoplus the driver for your database.Install
ttpryg/event-dispatcherif you need a PSR-14 dispatcher implementation.
🚀 Quick Start
use Psr\EventDispatcher\EventDispatcherInterface; use Ttpryg\CommentEngine\Application\DTO\CreateCommentRequest; use Ttpryg\CommentEngine\Application\Service\CommentService; use Ttpryg\CommentEngine\Domain\Event\CommentCreatedEvent; use Ttpryg\CommentEngine\Infrastructure\Persistence\Pdo\PdoCommentRepository; use Ttpryg\EventDispatcher\Dispatcher\EventDispatcher; use Ttpryg\EventDispatcher\Provider\ListenerProvider; // 1. Wire a PSR-14 dispatcher (ttpryg/event-dispatcher shown here) $provider = new ListenerProvider; $provider->addListener(CommentCreatedEvent::class, function (CommentCreatedEvent $event): void { // notify the moderator queue, write an audit entry, etc. }); $dispatcher = new EventDispatcher($provider); // 2. Build the service with any CommentRepositoryInterface implementation $comments = new CommentService(new PdoCommentRepository($pdo), $dispatcher); // 3. Create a top-level comment (starts as "pending") for tenant "acme" $comment = $comments->create(new CreateCommentRequest( tenantId: 'acme', commentableType: 'content', commentableId: 42, authorId: 7, body: 'Great article!' )); // 4. Reply to it (parent must exist on the same target and tenant) $comments->create(new CreateCommentRequest( tenantId: 'acme', commentableType: 'content', commentableId: 42, authorId: 8, body: 'Agreed!', parentId: $comment->getId() )); // 5. Moderate and read the public, approved listing for the tenant $comments->moderate( tenantId: 'acme', id: $comment->getId(), status: 'approved', moderatorId: 1 ); $approved = $comments->getComments( tenantId: 'acme', commentableType: 'content', commentableId: 42 );
The event dispatcher is optional. Pass null (or omit it) when the module is used without events.
Methods take several int|string identifiers, so prefer named arguments (tenantId:, id:, actorId:) to avoid swapping them silently.
🧱 Public API
CommentService
| Method | Behaviour |
|---|---|
create(CreateCommentRequest): Comment |
Creates a pending comment or reply; the tenant comes from the request. Dispatches CommentCreatedEvent. |
update(int|string|null $tenantId, int|string $id, string $body, int|string $actorId): Comment |
The author edits the body; dispatches CommentUpdatedEvent. |
delete(int|string|null $tenantId, int|string $id, int|string $actorId, bool $asModerator = false, bool $softDelete = true): bool |
Author (or authorized moderator) deletes; dispatches CommentDeletedEvent. |
moderate(int|string|null $tenantId, int|string $id, CommentStatus|string $status, int|string $moderatorId): Comment |
Applies approved, rejected, or spam; dispatches the matching moderation event. |
getComments(int|string|null $tenantId, string $type, int|string $id, CommentStatus|string|null $status = CommentStatus::APPROVED, int $limit = 20, int $offset = 0): array |
Chronological listing for one target; approved by default. Pass null for all statuses. |
getComment(int|string|null $tenantId, int|string $id, bool $includeTrashed = false): Comment |
Fetches one comment or throws CommentNotFoundException. |
countComments(int|string|null $tenantId, string $type, int|string $id, CommentStatus|string|null $status = CommentStatus::APPROVED): int |
Counts comments for one target. |
Every method accepts tenantId: null to run unscoped. See Multi-Tenancy.
Repository port
Ttpryg\CommentEngine\Domain\Repository\CommentRepositoryInterface is free of SQL and framework types. Bundled adapters:
Infrastructure\Persistence\Pdo\PdoCommentRepository(MySQL/MariaDB/SQLite).Infrastructure\Persistence\InMemory\InMemoryCommentRepository(tests/prototypes).
save() inserts a new comment and assigns its identifier; update() takes the tenant from the entity. delete() and restore() return false when the comment does not exist within the tenant scope. findAll() and count() exclude soft-deleted comments and reject a tenant_id key in their criteria — tenancy is only expressible through the explicit $tenantId argument. Use findById(..., includeTrashed: true) or findByTarget(..., includeTrashed: true) to inspect trashed rows.
Statuses
pending → approved | rejected | spam. Moderation cannot apply pending; a moderator can change a decision later.
Authorization
update() and delete() enforce author ownership in the application layer. delete(..., asModerator: true) skips the ownership check, so only set it after your own authorization policy has verified that the actor is a moderator. The same applies to moderate(): the caller is responsible for authorizing the moderator before invoking it.
Tenant scoping is enforced by the repository layer: every ID-based lookup filters by tenant_id, so a comment from another tenant looks nonexistent (CommentNotFoundException) rather than forbidden. Updates also persist within the entity's tenant. A null tenant disables the filter entirely and can see and mutate rows of every tenant — use it only for trusted, unscoped contexts such as migration or administration tooling.
🏢 Multi-Tenancy
Tenancy is optional and explicit. Pass the same tenantId on every operation for a tenant:
$comments->getComments(tenantId: 'acme', commentableType: 'content', commentableId: 42); $comments->getComment(tenantId: 'acme', id: $id); $comments->update(tenantId: 'acme', id: $id, body: 'Edited', actorId: 7);
- Isolation: scoped reads and writes only match rows with that
tenant_id. Fetching another tenant's ID returnsnull/CommentNotFoundException, so existence is not leaked. - Unscoped mode:
tenantId: nulldisables the tenant predicate. It is intended for single-tenant installs and trusted administration jobs, not for request handling. - Legacy rows: comments created before tenancy (or with
tenantId: null) havetenant_id IS NULLand are invisible to scoped callers. Backfill them before enabling tenancy:UPDATE comments SET tenant_id = 'acme' WHERE tenant_id IS NULL;
Creating withtenantId: nullwhile other tenants are active deliberately creates an unscoped row; scope those writes once tenancy is in use. - Replies:
CreateCommentUseCaserequires the parent to be in the same tenant. The database foreign key is parent-only, so it does not enforce this by itself. - Indexes: the composite tenant index is about 1 KB with
utf8mb4, which requires an InnoDBDYNAMICrow format (the MySQL 5.7.7+/8 default). - API shape:
tenantIdis always an explicit argument (int|string|null). Prefer named arguments when a call also takes comment and actor IDs.
🗄️ Database Schema
The migration creates the comments table plus indexes for target listings, author feeds, and moderation queues:
CREATE TABLE comments ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, commentable_type VARCHAR(50) NOT NULL, commentable_id VARCHAR(100) NOT NULL, parent_id BIGINT UNSIGNED NULL, author_id BIGINT UNSIGNED NULL, tenant_id VARCHAR(100) NULL, body TEXT NOT NULL, status ENUM('pending', 'approved', 'rejected', 'spam') NOT NULL DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, CONSTRAINT fk_comments_parent FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Indexes: (commentable_type, commentable_id, status, created_at), (author_id, created_at), (status, created_at), (tenant_id, commentable_type, commentable_id, status, created_at), and (tenant_id, author_id, created_at). The tenant-leading indexes overlap the original prefixes; they exist so scoped scans use tenant_id as the first key.
The self-referencing foreign key cascades hard deletes to replies. Soft deletes affect only the selected comment, so approved replies remain visible in public listings; use a hard delete to remove the whole reply subtree. The migration is driver-aware and works on both MySQL/MariaDB and SQLite. SQLite enforces foreign keys only when the connection enables them, so run PRAGMA foreign_keys = ON on SQLite connections if you rely on the cascade.
🔄 Database Migrations
Migrations live in database/migrations/ and run through ttpryg/database-migrator. Applied migrations are tracked in the comment_engine_migrations table; each migration is recorded only after it succeeds.
use Ttpryg\CommentEngine\Infrastructure\Database\DatabaseMigrator; $migrator = new DatabaseMigrator($pdo); $migrator->migrate(); // create/upgrade the schema $migrator->rollback(); // revert the latest migration $migrator->appliedMigrations(); // applied history
Migrations are never run automatically by the module. Run them from your deployment process.
Warning:
rollback()reverts only the latest migration (the tenant addition). Userollback(2)to undo the initial migration, whosedown()drops thecommentstable with all comment data. Back up the database before rolling back in production.
🛠️ Development
composer test # PHPUnit (unit + integration on in-memory SQLite) composer analyse # PHPStan level 8 composer fix # Rector + Pint (auto-fix) composer fix:check # Rector dry-run + Pint test + PHPStan (CI gate)
MySQL integration tests are skipped unless COMMENT_ENGINE_MYSQL_DSN is set (optionally COMMENT_ENGINE_MYSQL_USER and COMMENT_ENGINE_MYSQL_PASSWORD).
Docker
A PHP 8.1 CLI image and a MySQL 8 service are provided so the full suite — including MySQL integration — runs without a local PHP installation. The container runs as UID/GID 1000 by default; pass your own IDs if they differ:
docker compose build UID=$(id -u) GID=$(id -g) docker compose build # only if your IDs are not 1000 docker compose run --rm app composer install docker compose run --rm app composer test docker compose run --rm app composer fix:check
The app service waits for MySQL to become healthy before starting, so composer test exercises MySqlSchemaTest automatically. MySQL uses the comment_engine_test database and is exposed only on 127.0.0.1:3307 to avoid clashing with a local server; override the port with MYSQL_PORT. The MySQL integration test refuses to drop tables unless the database name ends with _test.
Run an interactive shell with docker compose run --rm app bash.
CI runs the same checks on PHP 8.1, 8.2, and 8.3 via GitHub Actions (.github/workflows/tests.yml), plus a dedicated MySQL 8 integration job. Dependabot keeps Composer and GitHub Actions dependencies up to date.
📄 License
MIT License.