After a successful deployment, the client runs a post-update step. It consists of two independent mechanisms, used individually or together:
Relevant files:
manage-client/lib/hooks.php – both mechanismsMANAGE_MIGRATIONS_DIR, MANAGE_MIGRATIONS_STATE, MANAGE_UPDATE_POST_HOOKOrder: migrations first, then the callback — so the callback can rely on the new schema. If a migration fails, the callback is not run.
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.
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.
[
"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.
<?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)");
}
};
<?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)
);
};
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"],
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.
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:
manageUpdateApply() returns normally, with deployed => true and
hook["success"] => false.1 and names the affected file — but
explicitly reports that deployment itself succeeded.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.
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.
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.
manageUpdateRunMigrations()