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.
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 staging. 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.
-
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.
-
Add or replace the entry idempotently — one
schedule:runline 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:runcron entry exists, pointing atdeploy/current/artisan.
- ✅ Exactly one
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 minute0 * * * * ← TRAP: once per hour at minute 0-
Confirm the cron row shows five asterisks.
Terminal window # The row must show five asterisksssh <staging-alias> 'crontab -l' | grep "schedule:run"# Expected: a line beginning "* * * * *" (five asterisks)- ✅ The
schedule:runrow begins with five asterisks, not0 * * * *.
- ✅ The
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
*(not0). - SSH
crontab -e— bypass the panel entirely and write the line yourself.
-
Confirm the cron actually fires.
Terminal window # Stream the log ~90s — healthy = no error flood; mtime updates within a minutessh <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:listruns clean.
- ✅ The log mtime updates within a minute and
3. Verify migrations
Section titled “3. Verify migrations”Confirm the installer-built schema is fully migrated and matches the decisions recorded in your ProjectLog.
-
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" Admin-Local/1-Project/1-ProjectInfo/ProjectLog.mdssh <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:statusis all “Ran” andmigrate --pretendmatches the ProjectLog.
- ✅
-
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.
-
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 --pretend | ProjectLog says | Action |
|---|---|---|
Nothing to migrate | no pending entry | All good |
| 1+ migrations shown | ”leave pending” | Expected — matches ProjectLog |
| 1+ migrations shown | no entry | Investigate — should match local |
Reconcile any count mismatch:
| Situation | Cause | Action |
|---|---|---|
| Staging has more files | stale composer cache on server | dep clear_composer_cache staging, 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 staging |
4. Export + diff the staging schema
Section titled “4. Export + diff the staging schema”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.
-
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 remainsdiff 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.
- ✅ The filtered diff is empty (or only
-
Commit the staging baseline.
Terminal window git add Admin-Local/1-Project/3-ProjectDB/1-Current/staging.sqlgit commit -m "Export staging schema baseline"# Expected: a commit recording the staging schema baseline- ✅
staging.sqlis committed as the staging baseline.
- ✅
Read the filtered diff:
| Filtered diff | Action |
|---|---|
Empty / only --- separators | Schemas match — commit staging.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 —
crontab -lshows aschedule:runentry with five asterisks (* * * * *), absolute PHP path, pointing atdeploy/current/artisan(👤 panel preset fix if needed). - 🤖 Scheduler firing — log mtime updates;
schedule:listruns clean; cron documented in_CUSTOMIZATIONS.md. - 🤖 Migrations verified —
migrate:statusall “Ran”;migrate --pretendmatches ProjectLog; vendor migration counts reconciled. - 🤖 Schema diffed — staging schema exported and diffed against local — no unexpected drift;
staging.sqlcommitted.