Laravel Changelog
Serves your application's Keep a Changelog CHANGELOG.md, with per-language translations, over a JSON API with configurable access, and checks the file with an Artisan command.
composer require intragile/laravel-changelog
Serves your application's Keep a Changelog CHANGELOG.md, with per-language translations,
over a JSON API with configurable access, and checks the file with an Artisan command.
- Headless. The package returns data; your SPA or Blade view draws the "What's new" page.
- One source of truth. The changelog you already keep is the content. No database, no second copy to maintain, no git access.
- Translations without a second system.
CHANGELOG.hu.mdsits next toCHANGELOG.md. A version missing from a translation falls back to the base file for that version and says so. - Any group name.
### Addedand friends, but also### Admin interfaceor whatever your file uses, plus a free summary under each version. - Safe HTML. Every item is rendered with raw HTML stripped and unsafe links removed, and is also returned as plain text.
- Access you choose. Public, signed-in users only, a gate, or off.
- A bad file does not take the page down. The parser never throws;
changelog:checkreports the problems in CI.
Installation
composer require intragile/laravel-changelog
Requires PHP 8.3+ and Laravel 12 or 13. The service provider is discovered automatically. Publish the config only if you need to change it:
php artisan vendor:publish --tag=changelog-config
Quick start
Read the access section first. With the shipped defaults (access is authenticated, and the
automatic routes sit in the api group only) a request passes only if your default guard
already recognises the user. For a typical token API (Sanctum) it does not, so the endpoints
answer 401 even to signed-in users.
Recommended: turn the automatic routes off and register the routes inside the route group that already authenticates your users. Publish the config first:
php artisan vendor:publish --tag=changelog-config
Then, in the published file, switch the automatic routes off and, while you are there, check
path (the changelog file, base_path('CHANGELOG.md') by default) and locales (every language
the API may serve, e.g. ['en', 'hu']):
// config/changelog.php
'path' => base_path('CHANGELOG.md'),
'locales' => ['en', 'hu'],
'routes' => [
'enabled' => false,
'prefix' => 'changelog',
'middleware' => ['api'],
'name' => 'changelog.',
],
// routes/api.php
use IntrAgile\Changelog\Facades\Changelog;
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Changelog::routes(); // GET /api/v1/changelog, /changelog/latest, /changelog/{version}
});
Changelog::routes() adds its own prefix (changelog) and route-name prefix (changelog.) from
the config, so the group above only supplies the outer prefix and the authentication.
Keep the whole routes array in your published config. Laravel merges package config only at the
top level, so a routes array you write replaces the shipped one; keys you leave out fall back to
the defaults in the table below.
Configuration
| Key | Default | Meaning |
|---|---|---|
path |
base_path('CHANGELOG.md') |
The base changelog file. |
translation_pattern |
CHANGELOG.{locale}.md |
Translation file name, resolved in the same directory as path. |
base_locale |
en |
The language of the base file. |
locales |
['en'] |
The locales the API accepts. List the base locale and every translation, e.g. ['en', 'hu']. |
access |
authenticated |
public, authenticated, a gate name or disabled. See below. |
routes.enabled |
true |
Register the routes automatically. Turn off to call Changelog::routes() yourself. |
routes.prefix |
changelog |
URL prefix. |
routes.middleware |
['api'] |
Middleware of the automatic route group. |
routes.name |
changelog. |
Route name prefix (changelog.index, changelog.latest, changelog.show). |
show_unreleased |
false |
Include the [Unreleased] section in the list and the programmatic API. |
per_page |
20 |
Default page size, in releases. |
max_per_page |
100 |
Upper limit for ?per_page. |
cache.store |
null |
Cache store; null uses the default store. |
cache.prefix |
changelog |
Cache key prefix. |
Access
access decides who may call the API. The check runs in a middleware that is part of every
package route, so it applies both to the automatic routes and to Changelog::routes().
| Value | Behaviour |
|---|---|
public |
Anyone. |
authenticated |
A user must be signed in; otherwise 401. |
| a gate name | Gate::allows(name) decides. A denied guest gets 401, a denied signed-in user 403. |
disabled |
404 for every route. |
authenticated checks $request->user() on the default guard. The package adds no
authentication of its own, and the default route group is only ['api'], so out of the box a
Sanctum token or a session user is not recognised and gets 401. Either:
-
Recommended:
routes.enabled = falseand callChangelog::routes()inside your own authenticated group, as in the quick start; or -
keep the automatic routes and add your authentication middleware:
'routes' => [ 'enabled' => true, 'prefix' => 'changelog', 'middleware' => ['api', 'auth:sanctum'], 'name' => 'changelog.', ],
If your Sanctum guard is not the default guard, a gate or a custom group is the safer choice.
Use public only for data you would also put on a public web page.
Middleware of the surrounding group (such as auth:sanctum) runs before the package's own
middleware, including the one that forces Accept: application/json. A guest who sends no
Accept header is therefore rejected by your application's own unauthenticated handling, which by
default redirects to a login route. Make your application render JSON for its API, for example
with $exceptions->shouldRenderJsonWhen(fn ($request) => $request->is('api/*')) in
bootstrap/app.php.
A gate is consulted for guests too, but Laravel only calls a gate callback for a guest if its first
parameter, the user, is nullable or defaults to null. To let guests through a gate, declare it that way:
Gate::define('view-changelog', fn (?User $user): bool => $user === null || $user->can_see_releases);
A callback that requires a User, or takes no parameter at all, denies every guest with 401.
API
All responses are JSON. The package forces Accept: application/json on its routes and throws
ordinary exceptions (AuthenticationException, ValidationException, NotFoundHttpException),
so your application's exception handler formats the errors, for example as
application/problem+json.
| Endpoint | Description |
|---|---|
GET {prefix} |
Releases, newest first (file order), paginated. |
GET {prefix}/latest |
The newest released version (never Unreleased). 404 if there is none. |
GET {prefix}/{version} |
One version, e.g. 1.4.0 or v1.4.0. 404 if unknown. |
Query parameters of the list: page (1 to 1000000) and per_page (1 to max_per_page). Invalid
values give a 422 validation error. Every endpoint also takes ?locale=.
{
"data": [
{
"version": "1.2.0",
"date": "2026-11-09",
"url": "https://github.com/acme/app/compare/v1.1.0...v1.2.0",
"unreleased": false,
"yanked": false,
"summary_html": "<p>A short free summary under the version heading.</p>",
"groups": [
{
"name": "Added",
"summary_html": null,
"items": [
{ "html": "<p>Export to <strong>CSV</strong>.</p>", "text": "Export to CSV." }
]
}
],
"locale": "hu",
"fallback": false
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 },
"locale": "hu",
"fallback": false
}
latestand{version}return a single release object, wrapped indata.urlis the link of the heading (inline or a[1.2.0]: https://...reference), ornull. Onlyhttp,httpsand relative URLs are passed on; any other scheme (javascript:,data:, ...) givesnull.- Item
htmlis rendered markdown;textis the same content as plain text. - Top level
localeis the resolved language. Top levelfallbackistruewhen any release on the returned page is shown in the base language because it has no translation. - Each release carries its own
locale(the language its text actually comes from) andfallback, so a page can mix translated and fallback versions.
Language resolution
The first of these that is a configured locale wins; anything not in locales falls through to
the next step:
?locale=huAccept-Language, in the client's order. Each language matches by its full tag first, then by its primary tag:pt-BRmatches a configuredpt-BR(orpt_BR), otherwisept;hu-HUmatcheshu. A bareptdoes not matchpt-BR.- the application locale (
app()->getLocale()) base_locale
A single-page application that keeps the user's language client-side should send ?locale= or
Accept-Language.
Translations
Put a translation beside the base file, named by translation_pattern: with the defaults,
CHANGELOG.hu.md next to CHANGELOG.md, and 'locales' => ['en', 'hu'].
- Version headings and dates must stay identical to the base file; translate only the text.
- A version missing from a translation is served from the base file for that version, with
fallback: trueandlocaleset to the base locale. A missing translation file does the same for every version. - Group names and summaries are translated freely; the translation is shown as written.
Checking the file
php artisan changelog:check
php artisan changelog:check --locale=hu # only this translation; it must exist
It reports, with file and line, parse errors (and warnings), a base file with no releases, a translation that lacks a released version, a translation that has a version the base does not, and dates that differ from the base file. It exits with a non-zero status when there is any error, so it can gate a release or a pull request:
# .github/workflows/tests.yml
- name: Check the changelog
run: php artisan changelog:check
[Unreleased] does not have to be translated. A translation file that does not exist is
skipped unless you name it with --locale.
Programmatic use
use IntrAgile\Changelog\Facades\Changelog;
Changelog::latest('hu'); // ?LocalizedRelease
Changelog::releases('hu'); // list<LocalizedRelease>, honours show_unreleased
Changelog::versions('hu'); // the same as releases()
Changelog::find('1.2.0', 'hu'); // ?LocalizedRelease
Changelog::parsed(); // the base file as written, with parse issues
A LocalizedRelease holds the parsed release, the locale it comes from and fallback.
Public API and semantic versioning
The package follows Semantic Versioning. From 1.0.0 on, a breaking change to any of the following waits for the next major version:
- the
Changelogfacade methods:releases(),versions(),find(),latest(),parsed(),translationPath()androutes(); - the readable properties of
LocalizedRelease,Release,ReleaseGroup,ParsedChangelogandParseIssue(and theParseIssue::ERROR/ParseIssue::WARNINGconstants); - the config keys and their meaning;
- the routes, their names and query parameters, and the JSON shapes documented above;
- the
changelog:checkcommand, its options and its exit status.
Not covered: constructing the value objects yourself (their constructors may gain parameters),
and every class or method marked @internal: the controller, middleware and resource, the
command class, the locale resolver, the markdown renderer, the parser and the repository
constructor. Resolve the repository from the container or use the facade.
Caching
Parsed releases are cached per locale, in the configured store, together with their rendered HTML
and text, under a key built from the path, modification time and size of the base and translation
files. A deploy that changes a file therefore shows up without clearing anything. Entries hold
only arrays and strings, so they work with stores that refuse to unserialize objects
(cache.serializable_classes = false), and they expire after a day so the keys of old deploys do
not pile up.
Limitation: an edit made within the same second as the previous one that leaves the file size exactly the same is not detected. It appears with the next change to the file, or after you clear the cache. In practice this only matters when you edit a file in place by hand; deploys replace files.
Security
Markdown is rendered with raw HTML stripped (html_input: strip) and unsafe link schemes
such as javascript: removed (allow_unsafe_links: false), so html is safe to insert into a
page. Locales only ever come from the configured allow-list before they reach a file name, so a
request cannot make the package read an arbitrary file. See SECURITY.md.
Testing and quality
composer test
composer analyse
vendor/bin/pint --test
License
MIT. See LICENSE.
Developed and maintained by IntrAgile Kft..
This page is built from the README of version v1.0.0 of the package. Source on GitHubopens in a new tab