[{"data":1,"prerenderedAt":588},["ShallowReactive",2],{"blog-post:\u002F2025-10-12-laravel-cron-jobs":3},{"id":4,"title":5,"body":6,"description":580,"extension":581,"meta":582,"navigation":461,"path":584,"seo":585,"stem":586,"__hash__":587},"content\u002F2025-10-12-laravel-cron-jobs.md","Running Laravel Cron Jobs in Docker with FrankenPHP",{"type":7,"value":8,"toc":573},"minimark",[9,13,49,57,60,100,107,118,152,162,165,174,183,193,196,204,207,209,213,223,230,338,341,391,406,409,411,415,422,436,547,549,553,560,569],[10,11,12],"p",{},"Yesterday I spent some time digging into how cron jobs work in Laravel. There are two fairly straightforward commands involved:",[14,15,20],"pre",{"className":16,"code":17,"language":18,"meta":19,"style":19},"language-bash shiki shiki-themes github-light github-dark","php artisan schedule:run\nphp artisan schedule:work\n","bash","",[21,22,23,39],"code",{"__ignoreMap":19},[24,25,28,32,36],"span",{"class":26,"line":27},"line",1,[24,29,31],{"class":30},"sScJk","php",[24,33,35],{"class":34},"sZZnC"," artisan",[24,37,38],{"class":34}," schedule:run\n",[24,40,42,44,46],{"class":26,"line":41},2,[24,43,31],{"class":30},[24,45,35],{"class":34},[24,47,48],{"class":34}," schedule:work\n",[10,50,51,52,56],{},"The idea is that the first command should be placed into the ",[53,54,55],"strong",{},"actual system crontab"," in whatever environment runs your backend (or any environment that has the required environment variables and access to the database).",[10,58,59],{},"Your crontab entry usually looks something like this:",[14,61,63],{"className":16,"code":62,"language":18,"meta":19,"style":19},"* * * * * cd \u002Fpath-to-your-project && php artisan schedule:run >> \u002Fdev\u002Fnull 2>&1\n",[21,64,65],{"__ignoreMap":19},[24,66,67,71,74,76,78,80,84,86,88,91,94,97],{"class":26,"line":27},[24,68,70],{"class":69},"szBVR","*",[24,72,73],{"class":69}," *",[24,75,73],{"class":69},[24,77,73],{"class":69},[24,79,73],{"class":69},[24,81,83],{"class":82},"sVt8B"," cd \u002Fpath-to-your-project && ",[24,85,31],{"class":30},[24,87,35],{"class":34},[24,89,90],{"class":34}," schedule:run",[24,92,93],{"class":69}," >>",[24,95,96],{"class":34}," \u002Fdev\u002Fnull",[24,98,99],{"class":69}," 2>&1\n",[10,101,102,103,106],{},"This means the command runs ",[53,104,105],{},"every minute",".",[10,108,109,110,113,114,117],{},"The idea behind this approach is to delegate the scheduling logic to ",[53,111,112],{},"PHP and Laravel",". When this command runs, Laravel checks ",[21,115,116],{},"app\u002FConsole\u002FKernel.php"," for registered scheduled tasks. They typically look something like this:",[14,119,122],{"className":120,"code":121,"language":31,"meta":19,"style":19},"language-php shiki shiki-themes github-light github-dark","$schedule->command('emails:send Taylor --force')->daily();\n",[21,123,124],{"__ignoreMap":19},[24,125,126,129,132,135,138,141,144,146,149],{"class":26,"line":27},[24,127,128],{"class":82},"$schedule",[24,130,131],{"class":69},"->",[24,133,134],{"class":30},"command",[24,136,137],{"class":82},"(",[24,139,140],{"class":34},"'emails:send Taylor --force'",[24,142,143],{"class":82},")",[24,145,131],{"class":69},[24,147,148],{"class":30},"daily",[24,150,151],{"class":82},"();\n",[10,153,154,155,158,159,106],{},"In this case, ",[21,156,157],{},"->daily()"," means the command is expected to run ",[53,160,161],{},"once per day",[163,164],"hr",{},[166,167,169,170,173],"h2",{"id":168},"what-does-schedulework-do","What does ",[21,171,172],{},"schedule:work"," do?",[10,175,176,177,179,180,106],{},"According to the documentation, the second command (",[21,178,172],{},") is intended ",[53,181,182],{},"only for local development",[10,184,185,186,189,190,106],{},"All it really does is run ",[21,187,188],{},"schedule:run"," every minute, but implemented in PHP using an ",[53,191,192],{},"infinite loop",[10,194,195],{},"You can see the original implementation here:",[10,197,198],{},[199,200,201],"a",{"href":201,"rel":202},"https:\u002F\u002Fgithub.com\u002Flaravel\u002Fframework\u002Fpull\u002F34618\u002Ffiles",[203],"nofollow",[10,205,206],{},"Modern Laravel versions have a much more complex implementation, but that initial pull request still gives a very clear understanding of how the mechanism works.",[163,208],{},[166,210,212],{"id":211},"my-setup-docker-frankenphp","My setup: Docker + FrankenPHP",[10,214,215,216,219,220,106],{},"I run my backend application in Docker using ",[53,217,218],{},"docker-compose",".\nThe base image is ",[21,221,222],{},"dunglas\u002Ffrankenphp",[10,224,225,226,229],{},"Here’s how the service is defined in ",[21,227,228],{},"docker-compose.yml",":",[14,231,235],{"className":232,"code":233,"language":234,"meta":19,"style":19},"language-yaml shiki shiki-themes github-light github-dark","backend-cron:\n  restart: unless-stopped\n  build:\n    target: dev\n  volumes:\n    - .:\u002Fapp\n  command: \"frankenphp php-cli artisan schedule:work\"\n  healthcheck:\n    disable: true\n  env_file:\n    .env\n","yaml",[21,236,237,246,257,265,276,284,293,304,312,324,332],{"__ignoreMap":19},[24,238,239,243],{"class":26,"line":27},[24,240,242],{"class":241},"s9eBZ","backend-cron",[24,244,245],{"class":82},":\n",[24,247,248,251,254],{"class":26,"line":41},[24,249,250],{"class":241},"  restart",[24,252,253],{"class":82},": ",[24,255,256],{"class":34},"unless-stopped\n",[24,258,260,263],{"class":26,"line":259},3,[24,261,262],{"class":241},"  build",[24,264,245],{"class":82},[24,266,268,271,273],{"class":26,"line":267},4,[24,269,270],{"class":241},"    target",[24,272,253],{"class":82},[24,274,275],{"class":34},"dev\n",[24,277,279,282],{"class":26,"line":278},5,[24,280,281],{"class":241},"  volumes",[24,283,245],{"class":82},[24,285,287,290],{"class":26,"line":286},6,[24,288,289],{"class":82},"    - ",[24,291,292],{"class":34},".:\u002Fapp\n",[24,294,296,299,301],{"class":26,"line":295},7,[24,297,298],{"class":241},"  command",[24,300,253],{"class":82},[24,302,303],{"class":34},"\"frankenphp php-cli artisan schedule:work\"\n",[24,305,307,310],{"class":26,"line":306},8,[24,308,309],{"class":241},"  healthcheck",[24,311,245],{"class":82},[24,313,315,318,320],{"class":26,"line":314},9,[24,316,317],{"class":241},"    disable",[24,319,253],{"class":82},[24,321,323],{"class":322},"sj4cs","true\n",[24,325,327,330],{"class":26,"line":326},10,[24,328,329],{"class":241},"  env_file",[24,331,245],{"class":82},[24,333,335],{"class":26,"line":334},11,[24,336,337],{"class":34},"    .env\n",[10,339,340],{},"Every minute I was getting the same error:",[14,342,344],{"className":16,"code":343,"language":18,"meta":19,"style":19},"backend-cron-1  | 2025-12-09T14:54:00.043306279Z sh: 1: : Permission denied\nbackend-cron-1  | 2025-12-09T14:55:00.041940906Z sh: 1: : Permission denied\n",[21,345,346,372],{"__ignoreMap":19},[24,347,348,351,354,357,360,363,366,369],{"class":26,"line":27},[24,349,350],{"class":30},"backend-cron-1",[24,352,353],{"class":69},"  |",[24,355,356],{"class":30}," 2025-12-09T14:54:00.043306279Z",[24,358,359],{"class":34}," sh:",[24,361,362],{"class":34}," 1:",[24,364,365],{"class":34}," :",[24,367,368],{"class":34}," Permission",[24,370,371],{"class":34}," denied\n",[24,373,374,376,378,381,383,385,387,389],{"class":26,"line":41},[24,375,350],{"class":30},[24,377,353],{"class":69},[24,379,380],{"class":30}," 2025-12-09T14:55:00.041940906Z",[24,382,359],{"class":34},[24,384,362],{"class":34},[24,386,365],{"class":34},[24,388,368],{"class":34},[24,390,371],{"class":34},[10,392,393,394,397,398,401,402,405],{},"At first, based on past experience, I assumed the script simply ",[53,395,396],{},"didn't have permission to write to the filesystem",". This is a common Laravel issue when running inside Docker, usually related to the ",[21,399,400],{},"storage"," and ",[21,403,404],{},"bootstrap\u002Fcache"," directories.",[10,407,408],{},"But that wasn’t the case here.",[163,410],{},[166,412,414],{"id":413},"the-actual-problem","The actual problem",[10,416,417,418,421],{},"It turned out that ",[53,419,420],{},"FrankenPHP cannot spawn subprocesses"," in this setup for some reason.",[10,423,424,425,428,429,432,433,435],{},"I tried playing around with ",[21,426,427],{},"setcap"," in the Docker image, but eventually gave up and just wrote a simple ",[53,430,431],{},"bash script"," that replicates what ",[21,434,172],{}," does:",[14,437,441],{"className":438,"code":439,"language":440,"meta":19,"style":19},"language-sh shiki shiki-themes github-light github-dark","#!\u002Fbin\u002Fsh\nset -e\n\necho \"Starting schedule loop...\"\n\nwhile true; do\n    # Wait until the start of the next minute\n    sleep $((60 - $(date +%S)))\n\n    # Run the scheduler\n    frankenphp php-cli artisan schedule:run\ndone\n","sh",[21,442,443,449,457,463,471,475,489,494,520,524,529,541],{"__ignoreMap":19},[24,444,445],{"class":26,"line":27},[24,446,448],{"class":447},"sJ8bj","#!\u002Fbin\u002Fsh\n",[24,450,451,454],{"class":26,"line":41},[24,452,453],{"class":322},"set",[24,455,456],{"class":322}," -e\n",[24,458,459],{"class":26,"line":259},[24,460,462],{"emptyLinePlaceholder":461},true,"\n",[24,464,465,468],{"class":26,"line":267},[24,466,467],{"class":322},"echo",[24,469,470],{"class":34}," \"Starting schedule loop...\"\n",[24,472,473],{"class":26,"line":278},[24,474,462],{"emptyLinePlaceholder":461},[24,476,477,480,483,486],{"class":26,"line":286},[24,478,479],{"class":69},"while",[24,481,482],{"class":322}," true",[24,484,485],{"class":82},"; ",[24,487,488],{"class":69},"do\n",[24,490,491],{"class":26,"line":295},[24,492,493],{"class":447},"    # Wait until the start of the next minute\n",[24,495,496,499,502,505,508,511,514,517],{"class":26,"line":306},[24,497,498],{"class":30},"    sleep",[24,500,501],{"class":82}," $((",[24,503,504],{"class":30},"60",[24,506,507],{"class":34}," -",[24,509,510],{"class":82}," $(",[24,512,513],{"class":30},"date",[24,515,516],{"class":34}," +%S",[24,518,519],{"class":82},")))\n",[24,521,522],{"class":26,"line":314},[24,523,462],{"emptyLinePlaceholder":461},[24,525,526],{"class":26,"line":326},[24,527,528],{"class":447},"    # Run the scheduler\n",[24,530,531,534,537,539],{"class":26,"line":334},[24,532,533],{"class":30},"    frankenphp",[24,535,536],{"class":34}," php-cli",[24,538,35],{"class":34},[24,540,38],{"class":34},[24,542,544],{"class":26,"line":543},12,[24,545,546],{"class":69},"done\n",[163,548],{},[166,550,552],{"id":551},"thoughts-on-frankenphp","Thoughts on FrankenPHP",[10,554,555,556,559],{},"Up until this point, I hadn't experienced any issues with FrankenPHP. It really worked as a ",[53,557,558],{},"drop-in replacement"," for the images I used before.",[10,561,562,563,568],{},"I haven't tried ",[199,564,567],{"href":565,"rel":566},"https:\u002F\u002Ffrankenphp.dev\u002Fdocs\u002Fworker\u002F",[203],"worker mode"," yet mostly because I expect it will require quite a bit of tweaking in the application itself to get everything running properly.",[570,571,572],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":19,"searchDepth":41,"depth":41,"links":574},[575,577,578,579],{"id":168,"depth":41,"text":576},"What does schedule:work do?",{"id":211,"depth":41,"text":212},{"id":413,"depth":41,"text":414},{"id":551,"depth":41,"text":552},"How to run Laravel cron jobs in Docker using FrankenPHP","md",{"date":583},"2025-10-12","\u002F2025-10-12-laravel-cron-jobs",{"title":5,"description":580},"2025-10-12-laravel-cron-jobs","QcDe0k_7TznpSPnwB5xrDozjRN6G4UhyJvMjbEtpHe8",1788961315295]