Skip to content
prod e051e98
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 (catch the hPanel “once per minute” preset that inserts 0 * * * *), and schema drift between local and staging — by installing the every-minute cron, verifying migrations, and exporting + diffing the staging schema against your local baseline.

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 staging. 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. Check whether the cron is already installed and find the PHP binary.

    Terminal window
    # Is it already installed?
    ssh <staging-alias> 'crontab -l 2>/dev/null' | grep -c "schedule:run" # want 1+
    # Find the exact PHP binary (Hostinger CloudLinux uses /opt/alt/phpXX/usr/bin/php)
    ssh <staging-alias> 'which php; ls /opt/alt/ 2>/dev/null; ls /usr/local/bin/php* 2>/dev/null'
    # Expected: a count (0 = not installed) and the available PHP binary paths
    • ✅ You know whether the cron exists and where the versioned PHP binary lives.
  2. Add or replace the entry idempotently — one schedule:run line per server; re-running must not duplicate. Substitute the real PHP path and host username.

    Terminal window
    PHP_BIN=$(grep "set('bin/php'" deploy.php | grep -oE "/[^'\"]+/php[^'\"]*" | head -1)
    PHP_BIN="${PHP_BIN:-php}"
    CRON_LINE="* * * * * $PHP_BIN /home/USER/domains/staging.yourapp.com/deploy/current/artisan schedule:run >> /dev/null 2>&1"
    ssh <staging-alias> "(crontab -l 2>/dev/null | grep -v 'schedule:run' ; echo \"\$CRON_LINE\") | crontab -"
    ssh <staging-alias> 'crontab -l 2>/dev/null | grep -c "schedule:run"' # must print exactly 1
    # Expected: crontab contains exactly one "* * * * * … schedule:run" line
    • ✅ Exactly one schedule:run cron entry exists, pointing at deploy/current/artisan.

2. Verify the cron runs every minute (the preset trap)

Section titled “2. Verify the cron runs every minute (the preset trap)”

If you set the cron through a hosting panel instead of SSH, you may have hit the single most common scheduler bug on shared hosting: the “Once per minute” preset inserts 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 cron row shows five asterisks.

    Terminal window
    # The row must show five asterisks
    ssh <staging-alias> 'crontab -l' | grep "schedule:run"
    # Expected: a line beginning "* * * * *" (five asterisks)
    • ✅ The schedule:run row begins with five asterisks, not 0 * * * *.

If your panel used a preset, a person must fix it one of three ways:

  • Custom expression field — enter * * * * * manually.
  • Five separate fields — set all five to * (not 0).
  • SSH crontab -e — bypass the panel entirely and write the line yourself.
  1. Confirm the cron actually fires.

    Terminal window
    # Stream the log ~90s — healthy = no error flood; mtime updates within a minute
    ssh <staging-alias> "tail -F ~/domains/staging.yourapp.com/deploy/current/storage/logs/laravel.log"
    # What does the scheduler know about? (empty is fine for a fresh vendor install)
    ssh <staging-alias> "cd ~/domains/staging.yourapp.com/deploy/current && php artisan schedule:list"
    # Expected: log mtime updates within a minute; schedule:list runs clean
    • ✅ The log mtime updates within a minute and schedule:list runs clean.

Confirm the installer-built schema is fully migrated and matches the decisions recorded in your ProjectLog.

  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" Admin-Local/1-Project/1-ProjectInfo/ProjectLog.md
    ssh <staging-alias> "cd ~/domains/staging.yourapp.com/deploy/current && php artisan migrate:status" # expect all "Ran"
    ssh <staging-alias> "cd ~/domains/staging.yourapp.com/deploy/current && php artisan migrate --pretend" # expect "Nothing to migrate"
    # Expected: migrate:status all "Ran"; migrate --pretend matches the ProjectLog
    • migrate:status is all “Ran” and migrate --pretend matches the ProjectLog.
  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 <staging-alias> "cd ~/domains/staging.yourapp.com/deploy/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 <staging-alias> "cd ~/domains/staging.yourapp.com/deploy/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 the ProjectLog:

migrate --pretendProjectLog saysAction
Nothing to migrateno pending entryAll good
1+ migrations shown”leave pending”Expected — matches ProjectLog
1+ migrations shownno entryInvestigate — should match local

Reconcile any count mismatch:

SituationCauseAction
Staging has more filesstale composer cache on serverdep clear_composer_cache staging, 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 staging

Capture the staging 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 staging schema and diff it against local.

    Terminal window
    # mysqldump (free) — or Atlas if installed (see page 9)
    ssh <staging-alias> "mysqldump --no-data -u USER -p DB_NAME" \
    > Admin-Local/1-Project/3-ProjectDB/1-Current/staging.sql
    # Filter out dump noise (server version, AUTO_INCREMENT, charset headers) — real drift remains
    diff Admin-Local/1-Project/3-ProjectDB/1-Current/local.sql \
    Admin-Local/1-Project/3-ProjectDB/1-Current/staging.sql \
    | 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 staging baseline.

    Terminal window
    git add Admin-Local/1-Project/3-ProjectDB/1-Current/staging.sql
    git commit -m "Export staging schema baseline"
    # Expected: a commit recording the staging schema baseline
    • staging.sql is committed as the staging baseline.

Read the filtered diff:

Filtered diffAction
Empty / only --- separatorsSchemas match — commit staging.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 correctlycrontab -l shows a schedule:run entry with five asterisks (* * * * *), absolute PHP path, pointing at deploy/current/artisan (👤 panel preset fix if needed).
  • 🤖 Scheduler firing — log mtime updates; schedule:list runs clean; cron documented in _CUSTOMIZATIONS.md.
  • 🤖 Migrations verifiedmigrate:status all “Ran”; migrate --pretend matches ProjectLog; vendor migration counts reconciled.
  • 🤖 Schema diffed — staging schema exported and diffed against local — no unexpected drift; staging.sql committed.