07_POST_UPDATE_HOOKS.md 6.5 KB

Post-Update Hook and Migrations

Overview

After a successful deployment, the client runs a post-update step. It consists of two independent mechanisms, used individually or together:

  1. Migrations – ordered, one-time scripts shipped with the release. The usual place for database changes.
  2. Project callback – a project function that runs after every update. For clearing caches, rebuilding derived files, setting permissions.

Relevant files:

  • manage-client/lib/hooks.php – both mechanisms
  • MANAGE_MIGRATIONS_DIR, MANAGE_MIGRATIONS_STATE, MANAGE_UPDATE_POST_HOOK

Order: migrations first, then the callback — so the callback can rely on the new schema. If a migration fails, the callback is not run.

Migrations

Location

Migrations live in the directory from MANAGE_MIGRATIONS_DIR (default migrations/ in the application root) and are shipped with the release package.

migrations/
  2026-08-20-01-add-orders-index.php
  2026-08-21-01-backfill-categories.php

Run in filename order. A date prefix with a running number sorts reliably. The filename without .php is the migration's id; renaming an already-run file makes it run again.

Structure

Recommended form — the file returns a function:

<?php

return function (array $context): void {
    $context["pdo"]->exec("ALTER TABLE orders ADD INDEX idx_created (created_at)");
};

Alternatively, the file defines a function up():

<?php

function up(array $context): void
{
    // …
}

The returned-function form is the better choice: two migrations that both define up() would collide within the same process.

The context

[
    "app_root"     => "/var/www/myproject",
    "instance"     => "myproject-prod",
    "from_version" => "v1.2.3",
    "to_version"   => "v1.3.0",
    "backup_dir"   => "/…/data/manage/updates/20260820-092114-v1.3.0",
    "run_id"       => "20260820-092114",
    "migration_id" => "2026-08-20-01-add-orders-index",
    "pdo"          => PDO,   // only when MANAGE_BACKUP_DATABASE is configured
]

pdo uses the credentials already configured for the database dump. A second configuration isn't needed. Projects without a database work with app_root.

Example: database

<?php

return function (array $context): void {
    $pdo = $context["pdo"];

    // Keep it idempotent: the migration can run again after a partial failure.
    $exists = $pdo->query(
        "SELECT COUNT(*) FROM information_schema.statistics
         WHERE table_schema = DATABASE()
           AND table_name = 'orders'
           AND index_name = 'idx_created'"
    )->fetchColumn();

    if ((int) $exists === 0) {
        $pdo->exec("ALTER TABLE orders ADD INDEX idx_created (created_at)");
    }
};

Example: JSON data

<?php

return function (array $context): void {
    $file = $context["app_root"] . "/data/products.json";
    $data = json_decode((string) file_get_contents($file), true) ?: [];

    foreach ($data as $index => $product) {
        if (!array_key_exists("category_id", $product)) {
            $data[$index]["category_id"] = null;
        }
    }

    file_put_contents(
        $file,
        json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    );
};

State

Executed migrations are recorded in MANAGE_MIGRATIONS_STATE:

{
    "applied": [
        {
            "id": "2026-08-20-01-add-orders-index",
            "applied_at": "2026-08-20T09:21:14+00:00",
            "version": "v1.3.0",
            "duration_ms": 42
        }
    ]
}

This file lives in the data directory and is therefore exempt from updates. It should be included in the backup if data/manage/ is among the sources — by default it isn't, because the backup directory would otherwise back up itself. To include the migration state, add it individually:

["as" => "manage", "file" => "data/manage/migrations.json"],

Project callback

define("MANAGE_UPDATE_POST_HOOK", [
    "file"     => MANAGE_APP_ROOT . "/includes/after-update.php",
    "callback" => "myProjectAfterUpdate",
]);
<?php

function myProjectAfterUpdate(array $context): void
{
    // Clear caches, rebuild derived files, create a directory …
    array_map("unlink", glob($context["app_root"] . "/data/cache/*.php") ?: []);
}

Counted as failure: a thrown exception, return false, or return ["success" => false, "error" => "…"]. Everything else counts as success.

The callback receives the same context as a migration, plus migrations with the list of ids executed in this run.

Failure behavior

The most important point: at this point the files are already deployed, and there is no rollback. A failure is therefore reported loudly instead of swallowed silently.

Specifically:

  • The run stops at the first failed migration. The following ones stay pending and are not attempted.
  • The callback is skipped if a migration failed.
  • manageUpdateApply() returns normally, with deployed => true and hook["success"] => false.
  • The command line exits with code 1 and names the affected file — but explicitly reports that deployment itself succeeded.
  • The UI shows a red notice with the migration's name.
  • Both are recorded in the client log.

Recovery after a failure:

# 1. Fix the cause (correct the migration, set permissions, check the database)
# 2. Look at pending migrations
php manage-client/bin/manage-client.php migrate --dry-run
# 3. Catch up
php manage-client/bin/manage-client.php migrate

Running update --force again redeploys the files, but does not re-run migrations that already ran.

Idempotency

Migrations should be safe to run more than once. Reason: if a migration fails partway through — say, after half of a data conversion — it is not recorded as applied and runs again from the start on the next migrate. Only an idempotent migration survives that unscathed.

In practice that means: check whether the change is already there before making it, and write data conversions so that already-converted rows are skipped.

Deploying without the post-update step

php manage-client/bin/manage-client.php update --skip-hook

Deploys only the files. Migrations stay pending and can be caught up later with migrate. Useful when the files are urgently needed but the database change belongs in a maintenance window.

Next