Three patches across the day. A lot of debugging, very satisfying when everything finally clicked into place.
Cron Chain Fixes
RunScheduler was crashing on startup. The command was reading $task['job_class'] but the live DB table only has a command column — the migration that would have added job_class was silently skipped because the table already existed when it ran. Rewrote the scheduler to use command and execute via exec(PHP_BINARY . spark …) directly.
The publish command (automation:publish-posts) was never running at all — cron.sh was missing the run_posts() call in the all case. The PHP binary issue: cron.sh was defaulting to the system php which is an older version on this server. CloudPanel does not set environment variables for cron processes, so PHP_BIN was always empty. Hardcoded the default to the correct versioned binary path.
Timezone bug: the app timezone was set to UTC but the admin form submits browser-local datetime-local values (Bangladesh, UTC+6). Posts scheduled at 6 PM were being stored as 18:00 UTC, meaning the cron never matched them against NOW() which was 12:00 UTC at that time. Changed app timezone to Asia/Dhaka. Posts now schedule and publish correctly.
Facebook Page Token Fix
This was the one that had me stumped for a while. The OAuth flow was storing the user's long-lived access token and user ID — but posting to a Facebook Page requires a page access token and page ID, not the user token. A user token does not have permission to post on behalf of a Page. Added fetchFacebookPageToken() (calls /me/accounts) and fetchFacebookPageInfo() to extract and store the correct page token at OAuth connect time.
DMs were also returning empty because fetchFacebookDms() was filtering out conversations with unread_count = 0. Messages already read in Messenger were being silently skipped. Removed the filter — deduplication by message_id is sufficient and correct.
LinkedIn OAuth and PHPStan Level 5
LinkedIn account was not saving after OAuth because the /v2/userinfo endpoint returns sub as the user ID, not id. The account_id was always empty, causing a silent model validation failure and a false success flash. Mapped sub → id and email → username. Same issue with Twitter: the /2/users/me endpoint wraps the user object under a data key, fields were not being read.
Fixed a false success on insert failure — exchangeCode() was calling insert() but never checking the return value. A validation failure silently returned without saving. Now throws a RuntimeException with the model's actual error messages so the callback shows a real error instead of a success banner.
OAuth callback was 404-ing because the callback route was only registered at one path but the migration seeded a different path in the default OAuth app config. Added the aliased route.
Facebook Login for Business: when a config_id is set, the scope parameter must not be sent — Facebook rejects it with "Invalid Scopes". Removed scope from the auth URL params when config_id is detected.
PHPStan raised to level 5 and brought to 0 errors: strict null checks throughout, removed redundant is_array on already-typed arrays, fixed curl_errno int used in boolean OR.
LinkedIn End-to-End Confirmed
By end of day: LinkedIn OAuth connects and saves correctly, auto-posting to LinkedIn via UGC Posts API confirmed working, comment fetch and AI reply working. LinkedIn DMs are intentionally not supported — LinkedIn removed DM API access from the Marketing API in 2023. The DM_PLATFORMS constant reflects that by design.