#4290·grav

Version update history for plugins & themes — a proposal

Author: phmg701Created Sep 7, 2026Updated Sep 9, 2026

Context

user/config/versions.yaml keeps an audit trail of core updates (core/grav history, written during grav upgrades via system/src/Grav/Installer/Install.php:330,350,442VersionUpdaterVersions::updateVersion()), but plugins and themes are never recorded. Confirmed on a live site: after years of GPM updates, the plugins: section is absent.

Origin. Reconstructing a site's upgrade history from its backup zip (user/config/versions.yaml inside the archive) produced an intact core.grav.history spanning several years, but no plugins: section at all, despite all those GPM updates. Plugin update times had to be inferred from each plugin's file modification time aligned to the nearest core-update timestamp; a precise record of what changed and when did not exist. This proposal closes that gap.

The underlying engine already supports this. Versions (system/src/Grav/Installer/Versions.php) has full plugins/<slug> / themes/<slug> support — getPlugins() (:81), getThemes() (:98), getHistory() (:194), updateVersion() (:160), and updateHistory() (:211) which appends ['version' => ..., 'date' => gmdate('Y-m-d H:i:s')], on a store that already defaults to USER_DIR . 'config/versions.yaml' (:35,37). It is simply never connected for plugins and themes: no call site exists outside Install.php.

In short: one small recording hook in the installer engine, one API endpoint, and a "version history" section in Admin2. Everything else is existing infrastructure.

Design decisions

Topic Recommendation Why
Recording point Inside Grav\Common\GPM\Installer::install() — the single engine choke point All paths funnel here: CLI gpm install/update, gpm direct-install, and the Admin2 web install/update (api/.../GpmService.php:125). One hook covers all current and future install paths, mirroring how core history is recorded (inside the install flow, not per-command)
Code shape One small named private static method on Installer Fits the class's existing style (loadInstaller :426, moveInstall :474, isValidDestination :677) and recent hardening helpers (isSafeArchiveEntry, archiveLimits, extractStreamed)
Which version to record The freshly installed blueprints.yaml version: Authoritative and identical across all three paths; avoids the $package->available vs ->version ambiguity in InstallCommand
Expose to Admin2 A new GET /gpm/versions endpoint One server-side read of versions.yaml, ETag-cacheable, doesn't bloat every PluginInfo payload; the UI reads a per-slug map
Where it shows up A "Version history" section on the plugin/theme detail pages It places the data where users look for update context. Themes reuse the shared ADMIN_NEXT.PLUGINS.* namespace already
History start No backfill — history begins from the release that ships this Same precedent as core.grav.history (started at 1.7.0-rc.20 when the feature shipped)
On uninstall Keep the history It's an audit store; removeHistory() (:224) serves skeleton building
Edge cases Silent skip if a blueprint/version is missing Never fail an otherwise good install because of metadata
Security Read-only endpoint behind api.gpm.read, no trust boundary crossed Comparable to what CHANGELOG/README endpoints already serve

Part 1 — Grav core (the engine)

Repo: getgrav/grav · File: system/src/Grav/Common/GPM/Installer.php

1a. Imports (Installer.php:12-20)

php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Utils;
+use Grav\Common\File\CompiledYamlFile;
+use Grav\Installer\Versions;
use RuntimeException;

1b. Hook at the success exit (Installer.php:166)

php
        self::$error = self::OK;

+       // Record version history for freshly installed / updated plugins and
+       // themes. Core payloads install under system/ so they never match the
+       // plugins|themes path below and stay recorded exclusively by
+       // Install::finalize().
+       self::recordVersionHistory($install_path);

        return true;

1c. New private static helper (right after loadInstaller(), before moveInstall() at :474)

php
    /**
     * Record a freshly installed / updated plugin or theme in
     * user/config/versions.yaml (the same store core/grav history is written
     * to by Install::finalize(), via VersionUpdater).
     *
     * @param string $install_path Fully resolved install destination.
     */
    private static function recordVersionHistory(string $install_path): void
    {
        if (!preg_match('|/(plugins|themes)/([^/]+)$|u', $install_path, $m)) {
            return; // System, binary, or root payload: core recording applies.
        }

        $type = $m[1];
        $slug = $m[2];

        $blueprints = $install_path . '/blueprints.yaml';
        if (!is_file($blueprints) || !is_readable($blueprints)) {
            return; // Best-effort metadata; never fail an otherwise good install.
        }

        $data = CompiledYamlFile::instance($blueprints)->content();
        $version = is_array($data) ? $data['version'] ?? null : null;
        if (!$version) {
            return;
        }

        Versions::instance()->updateVersion("{$type}/{$slug}", (string)$version)->save();
    }

Notes. Type + slug are derived from the destination path, consistent with the existing theme detection at Installer.php:141-143. Core self-upgrade payloads install under system/, so they're excluded here and stay recorded exclusively by Install::finalize() — no double-writes. Symlinked / "already exists" / preflight-failure branches already return false before :166, so only genuine installs and updates end up in history.


Part 2 — API plugin

Repo: getgrav/grav-plugin-api

2a. Route (classes/Api/ApiRouter.php — GPM block, after /gpm/upgrade at :826)

php
        $r->addRoute('POST', '/gpm/upgrade', [GpmController::class, 'upgrade']);
+       $r->addRoute('GET', '/gpm/versions', [GpmController::class, 'versions']);

2b. Controller (classes/Api/Controllers/GpmController.php)

Import — add to the use block (after use Grav\Common\GPM\Installer; at :8):

php
use Grav\Installer\Versions;

Handler modeled on updates() (GpmController.php:189-230). Insert right after it (~:231):

php
    /**
     * GET /gpm/versions - Installed versions and their recorded update history.
     *
     * Source: user/config/versions.yaml. History entries only exist for
     * updates made after the recording feature was released; there is no
     * backfill. Dates are UTC, Y-m-d H:i:s.
     */
    public function versions(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, self::PERMISSION_READ);

        $versions = Versions::instance();
        $data = [
            'grav' => [
                'version' => $versions->getVersion('core/grav') ?? GRAV_VERSION,
                'history' => $versions->getHistory('core/grav'),
            ],
            'plugins' => [],
            'themes' => [],
        ];

        foreach ($versions->getPlugins() as $slug => $section) {
            $data['plugins'][$slug] = [
                'version' => $section['version'] ?? null,
                'history' => $section['history'] ?? [],
            ];
        }
        foreach ($versions->getThemes() as $slug => $section) {
            $data['themes'][$slug] = [
                'version' => $section['version'] ?? null,
                'history' => $section['history'] ?? [],
            ];
        }

        return $this->respondWithEtag($data);
    }

On sites without a versions.yaml, the store simply yields empty arrays (the constructor already handles an absent file, Versions.php:318-326); respondWithEtag handles ETag/304 exactly like plugin() does (GpmController.php:116,183).

2c. OpenAPI (openapi.yaml)

Insert GET /gpm/versions after the /gpm/updates block (which ends just before /gpm/install at openapi.yaml:3144):

yaml
  /gpm/versions:
    get:
      operationId: getVersions
      tags: [GPM]
      summary: Installed versions and recorded update history
      description: |
        Installed version and recorded {version, date} history for Grav core,
        all installed plugins and all installed themes, sourced from
        user/config/versions.yaml. History starts accumulating at the release
        that introduced recording; there is no backfill. Requires
        `api.gpm.read` permission.
      responses:
        "200":
          description: Version map.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VersionsMap"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"

Schemas (under components.schemas):

yaml
    VersionsMap:
      type: object
      properties:
        grav:
          $ref: "#/components/schemas/VersionedPackage"
        plugins:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/VersionedPackage"
        themes:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/VersionedPackage"
    VersionedPackage:
      type: object
      properties:
        version:
          type: string
          nullable: true
        history:
          $ref: "#/components/schemas/VersionHistory"
    VersionHistory:
      type: array
      items:
        type: object
        properties:
          version:
            type: string
          date:
            type: string
            description: UTC, Y-m-d H:i:s.

Part 3 — Admin2 SPA

Repo: getgrav/grav-admin-next

3a. API client (src/lib/api/endpoints/gpm.ts)

Insert after checkUpdates() (gpm.ts:181-183):

typescript
export interface VersionEntry {
	version: string;
	date: string;
}

export interface VersionedPackage {
	version: string | null;
	history: VersionEntry[];
}

export interface VersionsResponse {
	grav: VersionedPackage;
	plugins: Record<string, VersionedPackage>;
	themes: Record<string, VersionedPackage>;
}

/**
 * Installed versions and their recorded update history (source:
 * user/config/versions.yaml on the server).
 */
export async function getVersions(): Promise<VersionsResponse> {
	return api.get<VersionsResponse>('/gpm/versions');
}

3b. Plugin detail page (src/routes/plugins/[slug]/+page.svelte)

Load once next to the existing getPlugin call (:101 area):

typescript
const versions = await getVersions();
const versionHistory = versions.plugins[slug]?.history ?? [];

Render a small section after the version header (<span>v{plugin.version}</span> at :461):

svelte
<section class="mt-6">
	<h2 class="text-sm font-semibold text-foreground">{i18n.t('ADMIN_NEXT.PLUGINS.VERSION_HISTORY')}</h2>
	{#if versionHistory.length > 0}
		<ul class="mt-2 space-y-1 text-sm text-muted-foreground">
			{#each versionHistory as entry}
				<li>v{entry.version} — {entry.date} UTC</li>
			{/each}
		</ul>
	{:else}
		<p class="mt-2 text-sm text-muted-foreground">{i18n.t('ADMIN_NEXT.PLUGINS.NO_VERSION_HISTORY')}</p>
	{/if}
</section>

Themes get the same treatment: src/routes/themes/[slug]/+page.svelte, anchored on v{theme.version} at :441, reading versions.themes[slug]?.history ?? [].

3c. Translations (grav-plugin-admin2/languages/<lang>.yaml)

Admin2 UI strings live in the Admin2 plugin languages (served through /api/v1/translations/{lang}). In en-US.yaml after CHANGELOG at :1099:

yaml
      CHANGELOG: Changelog
      VERSION_HISTORY: Version history
      NO_VERSION_HISTORY: No version history recorded yet.

And the same two keys synced across the other 23 language files (translated per language).


Files touched

Repo File Change
grav system/src/Grav/Common/GPM/Installer.php 2 imports, 1 call, 1 new private static method
grav-plugin-api classes/Api/Controllers/GpmController.php 1 import, 1 handler
grav-plugin-api classes/Api/ApiRouter.php 1 route
grav-plugin-api openapi.yaml 1 path + 3 schemas
grav-admin-next src/lib/api/endpoints/gpm.ts 3 interfaces + 1 fetch function
grav-admin-next src/routes/plugins/[slug]/+page.svelte history section
grav-admin-next src/routes/themes/[slug]/+page.svelte history section
grav-plugin-admin2 languages/*.yaml 2 keys × 24 files (en-US first)

Resulting behavior

  • plugins.<slug>.version + .history[] and themes.<slug>.version + .history[] start appearing in versions.yaml from the first recorded update onward (UTC Y-m-d H:i:s, matching core's format).
  • CLI gpm install, gpm update, gpm direct-install and the Admin2 web install/update all record — one hook, no per-command bookkeeping.
  • Core upgrade history is untouched (already recorded, and excluded by the plugins|themes guard).
  • Upgraded sites simply begin accumulating history from the release that ships this — no migration and no backfill required.

Verification

  • Core unit testsVersions has no coverage today; a round-trip test (updateVersiongetHistory) plus one for recordVersionHistory (path guard, missing blueprint, happy path).
  • Engine functional — on a scratch Grav installation: gpm install, gpm update, gpm direct-install, then assert plugins:/themes: sections appear with UTC {version, date} — and that a core self-upgrade adds no duplicate core/grav entry.
  • API — seed a versions.yaml, GET /gpm/versions: shape, ETag/304, api.gpm.read gating (401/403), and tolerance when the file is absent.
  • SPAsvelte-check + build; manually: detail pages show history or the empty-state string, and a package updated via the web UI shows up in history.

Impact assessment

Impact was evaluated relative to the project's security guidelines (whose severity model is trust-boundary based). All layers are admin-gated read paths: the engine hook runs only inside an admin-triggered install/update, the new endpoint is read-only and requires api.gpm.read (and is blocked in demo mode, api/.../Middleware/DemoModeMiddleware.php:38), and the Admin2 UI renders Svelte-escaped values inside the admin session. No trust boundary is crossed and nothing beyond what admins already see via GET /gpm/plugins is exposed. This is intended to go through the normal feature review channels.


This report was generated with AI assistance.