5 · Schedule, migrations + schema
Objective — close the two silent-failure modes the app itself can’t detect: a missing or mis-configured scheduler cron and schema drift between local and the selected non-production target — by installing the every-minute cron with the right provider path, verifying migrations, and exporting + diffing that target’s schema against your local baseline.
Steps at a glance:
- Install the
schedule:runcron — Laravel’s scheduler must firephp artisan schedule:runevery minute. Hostinger shared hosting should use the account-specific REST API first; panel and SSH are fallbacks. - Verify the cron runs every minute — Prove the row is
* * * * *, then prove Laravel’s scheduler runs cleanly with the selected environment’s versioned PHP binary. - Verify migrations — Confirm the installer-built schema is fully migrated and matches the decisions
recorded in
Zaj-CUSTOMIZATIONS.md. - Export + diff the selected target schema — Capture the selected non-production schema and compare it to your local baseline. Do not skip the diff — a one-line difference can be a missing column, index, or constraint.
Background
Section titled “Background”The installer created the schema; the previous page re-blocked /install and re-hardened permissions. Two silent-failure modes remain that the app itself can’t detect: a missing or mis-configured scheduler cron, and schema drift between local and the selected non-production environment. Both look fine until days later. Close them now.
1. Install the schedule:run cron
Section titled “1. Install the schedule:run cron”Laravel’s scheduler must fire php artisan schedule:run every minute — it drives queue processing, cleanup jobs, email retries, subscription renewals, and everything in routes/console.php / app/Console/Kernel.php. With no cron entry, the app appears healthy while background work silently queues and never runs.
-
Load the selected environment row and the account-specific cron inputs.
Terminal window ENV_KEY="staging-primary" # example; use the selected Zaj-PROJECT.md rowSSH_ALIAS="<non-prod-alias>"DEPLOY_DOMAIN="nonprod.example.com"DOMAIN_ROOT="/home/<user>/domains/$DEPLOY_DOMAIN"DEPLOY_PATH="$DOMAIN_ROOT/deploy"HOSTING_ACCOUNT_ID="<account-username-from-Zaj-PROJECT>"SHARED_INFRA_VAULT="General-Dev" # example default; use the actual vault from Zaj-PROJECT.mdHOSTING_TOKEN_REF="op://$SHARED_INFRA_VAULT/Hostinger-nonprod/HOSTINGER_API_TOKEN"PHP_BIN=$(grep "set('bin/php'" deploy.php | grep -oE "/[^'\"]+/php[^'\"]*" | head -1)test -n "$PHP_BIN" || { echo "STOP — bin/php missing from deploy.php"; exit 1; }SCHEDULE_SCRIPT="$DOMAIN_ROOT/Admin-Domain/scripts/laravel-schedule-run.sh"CRON_COMMAND="bash $SCHEDULE_SCRIPT"# Expected: values match Zaj-PROJECT.md, and CRON_COMMAND calls the wrapper script- ✅ The account ID/token pointer, PHP binary, deploy path, and cron command all match the selected non-production row.
-
Create the schedule wrapper script on the server.
Hostinger’s cron REST API stores a command; it does not run that command through a shell. If you put
>> /dev/null 2>&1directly in the API payload, those tokens are passed toartisanas arguments. Put redirects inside a script, then set the cron command tobash /absolute/path/script.sh.Terminal window ssh "$SSH_ALIAS" "DOMAIN_ROOT='$DOMAIN_ROOT' DEPLOY_PATH='$DEPLOY_PATH' PHP_BIN='$PHP_BIN' bash -s" <<'REMOTE'set -euo pipefailscript="$DOMAIN_ROOT/Admin-Domain/scripts/laravel-schedule-run.sh"mkdir -p "$(dirname "$script")" "$DEPLOY_PATH/shared/storage/logs"cat > "$script" <<SCRIPT#!/usr/bin/env bashset -euo pipefailcd "$DEPLOY_PATH/current"exec "$PHP_BIN" artisan schedule:run \>> "$DEPLOY_PATH/shared/storage/logs/schedule-run.log" 2>&1SCRIPTchmod 750 "$script"bash -n "$script"ls -l "$script"REMOTE# Expected: wrapper exists, is executable by owner/group, and bash -n exits 0- ✅ The wrapper script exists under
Admin-Domain/scripts/, uses the versioned PHP binary, and contains the only shell redirection.
- ✅ The wrapper script exists under
-
Hostinger shared hosting — install idempotently through the REST API.
Terminal window HOSTINGER_API_TOKEN="$(op read "$HOSTING_TOKEN_REF")"EXISTING_CRON=$(curl -fsS \-H "Authorization: Bearer $HOSTINGER_API_TOKEN" \"https://developers.hostinger.com/api/hosting/v1/accounts/$HOSTING_ACCOUNT_ID/cron-jobs" \| jq -r --arg command "$CRON_COMMAND" '.data[]?| select(.command == $command or (.command | contains("laravel-schedule-run.sh")) or (.command | contains("schedule:run")))| [.time,.command] | @tsv')printf '%s\n' "$EXISTING_CRON"# Expected: either no row yet, or the existing schedule wrapper row for this appif printf '%s\n' "$EXISTING_CRON" | grep -F "$CRON_COMMAND" >/dev/null; thenecho "Cron already exists for this app/account — no POST needed"elsejq -n --arg time "* * * * *" --arg command "$CRON_COMMAND" \'{time:$time, command:$command}' > /tmp/zaj-cron.jsoncurl -fsS -X POST \-H "Authorization: Bearer $HOSTINGER_API_TOKEN" \-H "Content-Type: application/json" \--data @/tmp/zaj-cron.json \"https://developers.hostinger.com/api/hosting/v1/accounts/$HOSTING_ACCOUNT_ID/cron-jobs"rm -f /tmp/zaj-cron.jsonfi# Expected: API creates one cron only when the exact app/account row was missing- ✅ Hostinger records exactly one
* * * * *cron for this app/account, and the command isbash .../laravel-schedule-run.sh.
- ✅ Hostinger records exactly one
If you need to inspect all scheduler rows without creating anything:
curl -fsS \ -H "Authorization: Bearer $HOSTINGER_API_TOKEN" \ "https://developers.hostinger.com/api/hosting/v1/accounts/$HOSTING_ACCOUNT_ID/cron-jobs" \| jq -r '.data[]? | select(.command | contains("laravel-schedule-run.sh") or contains("schedule:run")) | [.time,.command] | @tsv'# Expected: every schedule:run row is visible for review; no mutation happens2. Verify the cron runs every minute
Section titled “2. Verify the cron runs every minute”If you used a hosting panel, inspect the saved row before trusting it. Some panels label a preset “Once per minute” but save 0 * * * * (literal zero in the minute field = once per hour) instead of * * * * * (wildcard = every minute).
* * * * * ← correct: every minute0 * * * * ← TRAP: once per hour at minute 0-
Confirm the provider row shows five asterisks and the correct command.
Terminal window curl -fsS \-H "Authorization: Bearer $HOSTINGER_API_TOKEN" \"https://developers.hostinger.com/api/hosting/v1/accounts/$HOSTING_ACCOUNT_ID/cron-jobs" \| jq -r '.data[]? | select(.command | contains("laravel-schedule-run.sh") or contains("schedule:run")) | [.time,.command] | @tsv'# Expected: one row beginning "* * * * *" and calling the schedule wrapper- ✅ The
schedule:runrow begins with five asterisks, not0 * * * *.
- ✅ The
If your panel used a preset, fix it one of two ways:
- Custom expression field — enter
* * * * *manually. - Five separate fields — set all five to
*(not0).
-
Confirm Laravel’s scheduler runs cleanly with the versioned PHP binary.
Terminal window ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan schedule:list"ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan schedule:run --no-interaction -v"# Expected: both commands run cleanly; "No scheduled commands are ready to run" is healthy- ✅
schedule:listandschedule:runrun cleanly with the versioned PHP binary.
- ✅
3. Verify migrations
Section titled “3. Verify migrations”Confirm the installer-built schema is fully migrated and matches the decisions recorded in Zaj-CUSTOMIZATIONS.md.
-
Read prior migration decisions, then check migration status.
Terminal window # Read prior migration decisions first — a "leave pending" note means a pending row is expectedgrep -A5 "DATABASE/Migration" Zaj-CUSTOMIZATIONS.mdssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan migrate:status" # expect all "Ran"ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan migrate --pretend" # expect "Nothing to migrate"# Expected: migrate:status all "Ran"; migrate --pretend matches Zaj-CUSTOMIZATIONS.md- ✅
migrate:statusis all “Ran” andmigrate --pretendmatchesZaj-CUSTOMIZATIONS.md.
- ✅
-
Compare vendor migration counts (local vs staging).
Terminal window LOCAL=$(find vendor -path "*/database/migrations/*.php" 2>/dev/null | wc -l | tr -d ' ')STG=$(ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && find vendor -path '*/database/migrations/*.php' 2>/dev/null | wc -l | tr -d ' '")echo "local=$LOCAL staging=$STG"; [ "$LOCAL" = "$STG" ] && echo "match" || echo "MISMATCH"# Expected: "match" (or a MISMATCH you reconcile via the table below)- ✅ Vendor migration counts match, or any mismatch is reconciled per the table.
When the counts mismatch, list the differing files before assuming a problem — the file names tell stale-cache from incomplete-deploy apart.
-
Diff the vendor migration file lists (local vs staging).
Terminal window echo "=== Only on local (not staging) ==="diff <(find vendor -path "*/database/migrations/*.php" 2>/dev/null | sed 's|.*/vendor/|vendor/|' | sort) \<(ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && \find vendor -path '*/database/migrations/*.php' 2>/dev/null | sed 's|.*/vendor/|vendor/|' | sort") \| grep "^<" | sed 's/^< / /'# Expected: paths present locally but missing on staging (empty if only counts drifted)- ✅ You can see exactly which vendor migration files differ, and read the table below by their names.
Read the migration result against Zaj-CUSTOMIZATIONS.md:
migrate --pretend | Zaj-CUSTOMIZATIONS.md says | Action |
|---|---|---|
Nothing to migrate | no pending entry | All good |
| 1+ migrations shown | ”leave pending” | Expected — matches the recorded vendor/deploy migration decision |
| 1+ migrations shown | no entry | Investigate — should match local |
Reconcile any count mismatch:
| Situation | Cause | Action |
|---|---|---|
| Selected target has more files | stale composer cache on server | dep clear_composer_cache "$DEPLOY_TARGET", redeploy |
| Local has more — diff shows dev pkgs (debugbar/telescope/dusk) | --no-dev excluded them | Expected — no action |
| Local has more — diff shows non-dev pkgs | deploy didn’t finish cleanly | dep deploy "$DEPLOY_TARGET" |
4. Export + diff the selected target schema
Section titled “4. Export + diff the selected target schema”Capture the selected non-production schema and compare it to your local baseline. Do not skip the diff — a one-line difference can be a missing column, index, or constraint.
-
Dump the selected target schema and diff it against local.
Terminal window # Guard: the diff needs the local baseline from Phase 3. A missing local.sql makes# `diff` print "every line added" and look like total drift — fail clearly instead.LOCAL_SQL=Admin-Local/1-Project/6-Schema/1-Current/local.sql[ -s "$LOCAL_SQL" ] || { echo "❌ $LOCAL_SQL missing/empty — regenerate it locally first (mariadb-dump --no-data on Herd Pro, where mysqldump is renamed to mariadb-dump; mysqldump elsewhere)"; exit 1; }TARGET_SCHEMA="Admin-Local/1-Project/6-Schema/1-Current/${ENV_KEY}.sql"# Server-side dump (the CloudLinux server ships mysqldump) — Atlas if installed (see page 9)ssh "$SSH_ALIAS" "mysqldump --no-data -u USER -p DB_NAME" \> "$TARGET_SCHEMA"# Filter out dump noise (server version, AUTO_INCREMENT, charset headers) — real drift remainsdiff "$LOCAL_SQL" "$TARGET_SCHEMA" \| grep -v "^[<>] --\|^[<>] /\*\|AUTO_INCREMENT\|character_set_client\|MariaDB dump\|Server version\|Host:\|Database:"# Expected: empty (or only "---" separators) = schemas match- ✅ The filtered diff is empty (or only
---separators) — schemas match.
- ✅ The filtered diff is empty (or only
-
Commit the selected target baseline.
Terminal window TARGET_SCHEMA="Admin-Local/1-Project/6-Schema/1-Current/${ENV_KEY}.sql"git add "$TARGET_SCHEMA"git commit -m "Export ${ENV_KEY} schema baseline"# Expected: a commit recording the selected target schema baseline- ✅
${ENV_KEY}.sqlis committed as the selected target schema baseline.
- ✅
Read the filtered diff:
| Filtered diff | Action |
|---|---|
Empty / only --- separators | Schemas match — commit ${ENV_KEY}.sql |
| Table / column / index differences | STOP. Reconcile before production — drift here becomes a deploy failure there |
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🔀 Cron installed correctly — provider/API or panel row shows exactly one schedule wrapper entry with five asterisks (
* * * * *); wrapper uses the absolute PHP path anddeploy/current/artisan. - 🤖 Scheduler firing —
schedule:listandschedule:runrun clean with versioned PHP; wrapper path and cron documented inZaj-CUSTOMIZATIONS.md. - 🤖 Migrations verified —
migrate:statusall “Ran”;migrate --pretendmatchesZaj-CUSTOMIZATIONS.md; vendor migration counts reconciled. - 🤖 Schema diffed — selected target schema exported and diffed against local — no unexpected drift;
${ENV_KEY}.sqlcommitted.