Skip to content
prod 352bb92
Browse

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:

  1. Install the schedule:run cron — Laravel’s scheduler must fire php artisan schedule:run every minute. Hostinger shared hosting should use the account-specific REST API first; panel and SSH are fallbacks.
  2. 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.
  3. Verify migrations — Confirm the installer-built schema is fully migrated and matches the decisions recorded in Zaj-CUSTOMIZATIONS.md.
  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.

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.

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.

  1. Load the selected environment row and the account-specific cron inputs.

    Terminal window
    ENV_KEY="staging-primary" # example; use the selected Zaj-PROJECT.md row
    SSH_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.md
    HOSTING_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.
  2. 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>&1 directly in the API payload, those tokens are passed to artisan as arguments. Put redirects inside a script, then set the cron command to bash /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 pipefail
    script="$DOMAIN_ROOT/Admin-Domain/scripts/laravel-schedule-run.sh"
    mkdir -p "$(dirname "$script")" "$DEPLOY_PATH/shared/storage/logs"
    cat > "$script" <<SCRIPT
    #!/usr/bin/env bash
    set -euo pipefail
    cd "$DEPLOY_PATH/current"
    exec "$PHP_BIN" artisan schedule:run \
    >> "$DEPLOY_PATH/shared/storage/logs/schedule-run.log" 2>&1
    SCRIPT
    chmod 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.
  3. 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 app
    if printf '%s\n' "$EXISTING_CRON" | grep -F "$CRON_COMMAND" >/dev/null; then
    echo "Cron already exists for this app/account — no POST needed"
    else
    jq -n --arg time "* * * * *" --arg command "$CRON_COMMAND" \
    '{time:$time, command:$command}' > /tmp/zaj-cron.json
    curl -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.json
    fi
    # 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 is bash .../laravel-schedule-run.sh.

If you need to inspect all scheduler rows without creating anything:

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: every schedule:run row is visible for review; no mutation happens

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 minute
0 * * * * ← TRAP: once per hour at minute 0
  1. 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:run row begins with five asterisks, not 0 * * * *.

If your panel used a preset, fix it one of two ways:

  • Custom expression field — enter * * * * * manually.
  • Five separate fields — set all five to * (not 0).
  1. 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:list and schedule:run run cleanly with the versioned PHP binary.

Confirm the installer-built schema is fully migrated and matches the decisions recorded in Zaj-CUSTOMIZATIONS.md.

  1. Read prior migration decisions, then check migration status.

    Terminal window
    # Read prior migration decisions first — a "leave pending" note means a pending row is expected
    grep -A5 "DATABASE/Migration" Zaj-CUSTOMIZATIONS.md
    ssh "$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:status is all “Ran” and migrate --pretend matches Zaj-CUSTOMIZATIONS.md.
  2. 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.

  1. 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 --pretendZaj-CUSTOMIZATIONS.md saysAction
Nothing to migrateno pending entryAll good
1+ migrations shown”leave pending”Expected — matches the recorded vendor/deploy migration decision
1+ migrations shownno entryInvestigate — should match local

Reconcile any count mismatch:

SituationCauseAction
Selected target has more filesstale composer cache on serverdep clear_composer_cache "$DEPLOY_TARGET", redeploy
Local has more — diff shows dev pkgs (debugbar/telescope/dusk)--no-dev excluded themExpected — no action
Local has more — diff shows non-dev pkgsdeploy didn’t finish cleanlydep 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.

  1. 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 remains
    diff "$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.
  2. 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}.sql is committed as the selected target schema baseline.

Read the filtered diff:

Filtered diffAction
Empty / only --- separatorsSchemas match — commit ${ENV_KEY}.sql
Table / column / index differencesSTOP. Reconcile before production — drift here becomes a deploy failure there

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 and deploy/current/artisan.
  • 🤖 Scheduler firingschedule:list and schedule:run run clean with versioned PHP; wrapper path and cron documented in Zaj-CUSTOMIZATIONS.md.
  • 🤖 Migrations verifiedmigrate:status all “Ran”; migrate --pretend matches Zaj-CUSTOMIZATIONS.md; vendor migration counts reconciled.
  • 🤖 Schema diffed — selected target schema exported and diffed against local — no unexpected drift; ${ENV_KEY}.sql committed.