asdrubalp9 / laravel-changelog
Role-targeted changelog entries with per-user read tracking for Laravel.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/filesystem: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/routing: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- illuminate/validation: ^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.0
- league/commonmark: ^2.7
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
Suggests
- league/commonmark: ^2.7, required for render_html (Markdown to sanitized HTML).
Provides
None
Conflicts
None
Replaces
None
README
Publish role-targeted changelog entries and track what each user has read. Backend only: you build the interface.
Installation
composer require asdrubalp9/laravel-changelog
php artisan vendor:publish --tag=changelog-config
php artisan vendor:publish --tag=changelog-migrations
php artisan migrate
Set key_type, morph_key_type and scope_type to uuid before you migrate if your users table uses uuid keys.
The package works on PostgreSQL, MySQL and SQLite.
Minimal configuration
Define the ability the administration routes check. Without it, every administration route responds 403:
Gate::define('manage-changelog', fn (User $user) => $user->hasRole('platform-admin'));
The default routes.user_middleware and routes.admin_middleware are ['api', 'auth:sanctum']. Override both if you do not use Sanctum.
Audience
An entry lists the roles that can see it. A reader sees an entry if they have any of those roles. An entry with no roles is visible to everyone.
The package does not know your role system. It asks ResolvesAudience. The default implementation calls getRoleNames() when the user model has it (spatie/laravel-permission) and returns no roles otherwise.
$this->app->bind(ResolvesAudience::class, MyAudience::class);
knownRoles() returns the valid role names, or null to skip validation. Role names are stored as text. If you rename a role, entries that list the old name stop being visible and nothing reports it. Return your roles from knownRoles() so the administration API rejects a typo with a 422.
Markdown
body is stored as Markdown. Set render_html to true to also receive body_html. The package removes raw HTML and unsafe links (javascript:) and does not let you turn that off. render_html needs league/commonmark, which laravel/framework already requires.
API
User routes, under routes.prefix (default changelog):
| Method | Path | Result |
|---|---|---|
| GET | entries | Page of visible entries with read |
| GET | unread-count | { "data": { "unread": 3 } } |
| POST | entries/{id}/read | 204. 404 if not visible |
| POST | read-all | { "data": { "unread": 0 } } |
Administration routes, under admin/:
| Method | Path | Result |
|---|---|---|
| GET | entries | List, filter with ?status=draft\|scheduled\|live\|expired |
| GET | entries/{id} | Detail with roles |
| POST | entries | 201 |
| PUT | entries/{id} | 200, JSON |
| DELETE | entries/{id} | Soft delete |
| POST | entries/{id}/publish | Sets published_at to now unless already live |
| POST | entries/{id}/unpublish | Back to draft |
| POST | entries/{id}/image | Upload or replace, multipart |
| DELETE | entries/{id}/image | Remove |
The image goes through POST. PHP does not populate uploaded files for a multipart PUT.
Image URLs
By default image_url is the public URL of the disk (Storage::url()). For a private bucket, set image.url to temporary. The package then returns a signed URL that expires after image.url_ttl minutes (60 by default):
CHANGELOG_IMAGE_DISK=s3
CHANGELOG_IMAGE_URL=temporary
CHANGELOG_IMAGE_URL_TTL=60
The disk must be able to sign URLs. s3 can. Any other disk needs buildTemporaryUrlsUsing(). With a disk that cannot sign, every response that includes an entry with an image fails with changelog_image_disk_cannot_sign (HTTP 500), and that includes the list endpoint. Check the disk before you turn the mode on.
Any value of image.url other than temporary behaves as public. A typo does not raise an error.
A signed URL stops working when it expires. Reload the list to get a new one.
Dates
published_at and expires_at are normalized to app.timezone before they are stored. Laravel assumes app.timezone equals the database session timezone. Keep both in UTC.
Scopes
changelog_reads.scope_id holds a value from the ResolvesReaderScope contract. It lets your app write a row-level security policy without a join. The package never reads it.