[{"data":1,"prerenderedAt":2467},["ShallowReactive",2],{"post-/blog/passport-v13-secrets":3,"related-/blog/passport-v13-secrets":669},{"id":4,"title":5,"author":6,"body":7,"category":652,"description":653,"draft":654,"extension":655,"featured":654,"meta":656,"navigation":154,"ogImage":657,"path":658,"publishedAt":659,"readingTime":660,"seo":661,"stem":662,"tags":663,"updatedAt":659,"__hash__":668},"blog/blog/passport-v13-secrets.md","Laravel Passport v13: the plain-secret storage change that broke my OAuth flow","Jacques",{"type":8,"value":9,"toc":640},"minimark",[10,14,17,22,25,55,58,61,65,68,97,104,108,111,116,123,217,220,246,249,253,256,270,440,448,451,455,466,473,476,479,483,490,560,567,570,574,589,595,619,622,626,629,636],[11,12,13],"p",{},"Laravel Passport v13 shipped with a breaking change that isn't obvious from a quick glance at the upgrade guide: client secrets are now hashed by default, the same way user passwords are. If you upgrade an existing app without handling this properly, every OAuth consumer that was working yesterday will fail authentication today.",[11,15,16],{},"This post covers what actually changed, why it's a security improvement, and the migration path that avoids breaking production.",[18,19,21],"h2",{"id":20},"what-changed","What changed",[11,23,24],{},"Before v13, Passport stored OAuth client secrets in one of two ways depending on config:",[26,27,28,45],"ul",{},[29,30,31,35,36,40,41,44],"li",{},[32,33,34],"strong",{},"Plain text"," (the default for a long time) — the ",[37,38,39],"code",{},"secret"," column in ",[37,42,43],{},"oauth_clients"," contained the actual secret value",[29,46,47,50,51,54],{},[32,48,49],{},"Hashed"," — via the ",[37,52,53],{},"Passport::hashClientSecrets()"," opt-in, the column contained a bcrypt hash",[11,56,57],{},"The plain-text default was convenient: when you needed to remind yourself of a client's secret, you could literally look it up in the database. It was also a meaningful security risk — a database dump gave an attacker the keys to every OAuth integration on the platform.",[11,59,60],{},"In v13, this inverts. Hashed secrets are the default. The plain-text option still exists but is now the explicit opt-in, and the framework nudges hard toward leaving it off.",[18,62,64],{"id":63},"how-the-failure-shows-up","How the failure shows up",[11,66,67],{},"The symptoms after upgrading:",[26,69,70,84,87,90],{},[29,71,72,73,76,77,80,81],{},"Any first-party or third-party service that authenticates via ",[37,74,75],{},"client_credentials"," grant starts getting ",[37,78,79],{},"400 Bad Request"," with ",[37,82,83],{},"\"error\": \"invalid_client\"",[29,85,86],{},"Personal access tokens (which use a special client under the hood) may also fail to issue",[29,88,89],{},"The Passport UI's \"create client\" flow still works, but existing clients stop authenticating",[29,91,92,93,96],{},"No obvious error in ",[37,94,95],{},"storage/logs/laravel.log"," unless you've turned up OAuth server logging",[11,98,99,100,103],{},"The reason: Passport now compares the incoming secret against the stored column using ",[37,101,102],{},"Hash::check()",". Your plain-text stored secret doesn't match its own hash (because it's not hashed), so authentication fails.",[18,105,107],{"id":106},"the-migration-path","The migration path",[11,109,110],{},"There's no automatic migration for existing secrets. You have three options, in rough order of safety:",[112,113,115],"h3",{"id":114},"option-1-stay-on-plain-text-temporarily-then-migrate-deliberately","Option 1: Stay on plain-text temporarily, then migrate deliberately",[11,117,118,119,122],{},"In your ",[37,120,121],{},"AppServiceProvider::boot()",":",[124,125,130],"pre",{"className":126,"code":127,"language":128,"meta":129,"style":129},"language-php shiki shiki-themes github-dark","use Laravel\\Passport\\Passport;\n\npublic function boot(): void\n{\n    // Explicitly opt out of the new hashed default while you migrate\n    Passport::hashClientSecrets(false);\n}\n","php","",[37,131,132,149,156,177,183,190,211],{"__ignoreMap":129},[133,134,137,141,145],"span",{"class":135,"line":136},"line",1,[133,138,140],{"class":139},"snl16","use",[133,142,144],{"class":143},"sDLfK"," Laravel\\Passport\\Passport",[133,146,148],{"class":147},"s95oV",";\n",[133,150,152],{"class":135,"line":151},2,[133,153,155],{"emptyLinePlaceholder":154},true,"\n",[133,157,159,162,165,169,172,174],{"class":135,"line":158},3,[133,160,161],{"class":139},"public",[133,163,164],{"class":139}," function",[133,166,168],{"class":167},"svObZ"," boot",[133,170,171],{"class":147},"()",[133,173,122],{"class":139},[133,175,176],{"class":139}," void\n",[133,178,180],{"class":135,"line":179},4,[133,181,182],{"class":147},"{\n",[133,184,186],{"class":135,"line":185},5,[133,187,189],{"class":188},"sAwPA","    // Explicitly opt out of the new hashed default while you migrate\n",[133,191,193,196,199,202,205,208],{"class":135,"line":192},6,[133,194,195],{"class":143},"    Passport",[133,197,198],{"class":139},"::",[133,200,201],{"class":167},"hashClientSecrets",[133,203,204],{"class":147},"(",[133,206,207],{"class":143},"false",[133,209,210],{"class":147},");\n",[133,212,214],{"class":135,"line":213},7,[133,215,216],{"class":147},"}\n",[11,218,219],{},"This restores v12 behaviour. Your existing clients keep working. Then, on a schedule:",[221,222,223,226,229,236,239],"ol",{},[29,224,225],{},"Identify every active OAuth client",[29,227,228],{},"Reach out to the owner of each one (your team, partner integrations, etc.)",[29,230,231,232,235],{},"Rotate the secret — ",[37,233,234],{},"php artisan passport:client --rotate {client-id}"," — noting the new plain-text value",[29,237,238],{},"Give the owner the new secret, wait for them to update their integration",[29,240,241,242,245],{},"Once all clients are rotated, remove the ",[37,243,244],{},"hashClientSecrets(false)"," line and let v13's default take over",[11,247,248],{},"This is the safest option because nothing breaks. It's also the slowest.",[112,250,252],{"id":251},"option-2-rotate-all-secrets-in-one-maintenance-window","Option 2: Rotate all secrets in one maintenance window",[11,254,255],{},"If your integrations are all internal and you can coordinate a maintenance window:",[221,257,258,261,267],{},[29,259,260],{},"Put the service in maintenance mode",[29,262,263,264,266],{},"Enable ",[37,265,53],{}," explicitly",[29,268,269],{},"Run a command that regenerates every client secret:",[124,271,273],{"className":126,"code":272,"language":128,"meta":129,"style":129},"// In a one-off artisan command\nuse Laravel\\Passport\\Client;\nuse Illuminate\\Support\\Str;\n\n$clients = Client::all();\n\nforeach ($clients as $client) {\n    $newSecret = Str::random(40);\n    $client->secret = $newSecret;  // will be hashed on save\n    $client->save();\n\n    $this->info(\"Client {$client->id}: {$newSecret}\");\n}\n",[37,274,275,280,289,298,302,321,325,339,362,382,394,399,435],{"__ignoreMap":129},[133,276,277],{"class":135,"line":136},[133,278,279],{"class":188},"// In a one-off artisan command\n",[133,281,282,284,287],{"class":135,"line":151},[133,283,140],{"class":139},[133,285,286],{"class":143}," Laravel\\Passport\\Client",[133,288,148],{"class":147},[133,290,291,293,296],{"class":135,"line":158},[133,292,140],{"class":139},[133,294,295],{"class":143}," Illuminate\\Support\\Str",[133,297,148],{"class":147},[133,299,300],{"class":135,"line":179},[133,301,155],{"emptyLinePlaceholder":154},[133,303,304,307,310,313,315,318],{"class":135,"line":185},[133,305,306],{"class":147},"$clients ",[133,308,309],{"class":139},"=",[133,311,312],{"class":143}," Client",[133,314,198],{"class":139},[133,316,317],{"class":167},"all",[133,319,320],{"class":147},"();\n",[133,322,323],{"class":135,"line":192},[133,324,155],{"emptyLinePlaceholder":154},[133,326,327,330,333,336],{"class":135,"line":213},[133,328,329],{"class":139},"foreach",[133,331,332],{"class":147}," ($clients ",[133,334,335],{"class":139},"as",[133,337,338],{"class":147}," $client) {\n",[133,340,342,345,347,350,352,355,357,360],{"class":135,"line":341},8,[133,343,344],{"class":147},"    $newSecret ",[133,346,309],{"class":139},[133,348,349],{"class":143}," Str",[133,351,198],{"class":139},[133,353,354],{"class":167},"random",[133,356,204],{"class":147},[133,358,359],{"class":143},"40",[133,361,210],{"class":147},[133,363,365,368,371,374,376,379],{"class":135,"line":364},9,[133,366,367],{"class":147},"    $client",[133,369,370],{"class":139},"->",[133,372,373],{"class":147},"secret ",[133,375,309],{"class":139},[133,377,378],{"class":147}," $newSecret;  ",[133,380,381],{"class":188},"// will be hashed on save\n",[133,383,385,387,389,392],{"class":135,"line":384},10,[133,386,367],{"class":147},[133,388,370],{"class":139},[133,390,391],{"class":167},"save",[133,393,320],{"class":147},[133,395,397],{"class":135,"line":396},11,[133,398,155],{"emptyLinePlaceholder":154},[133,400,402,405,407,410,412,416,419,421,424,427,430,433],{"class":135,"line":401},12,[133,403,404],{"class":143},"    $this",[133,406,370],{"class":139},[133,408,409],{"class":167},"info",[133,411,204],{"class":147},[133,413,415],{"class":414},"sU2Wk","\"Client {",[133,417,418],{"class":147},"$client",[133,420,370],{"class":139},[133,422,423],{"class":147},"id",[133,425,426],{"class":414},"}: {",[133,428,429],{"class":147},"$newSecret",[133,431,432],{"class":414},"}\"",[133,434,210],{"class":147},[133,436,438],{"class":135,"line":437},13,[133,439,216],{"class":147},[221,441,442,445],{"start":179},[29,443,444],{},"Distribute the new secrets to the owners of each integration",[29,446,447],{},"Exit maintenance mode",[11,449,450],{},"Risky if you have many integrations or the owners are external. Fast if you have three internal services all run by your team.",[112,452,454],{"id":453},"option-3-use-the-last-seen-plain-secret-trick-for-migration","Option 3: Use the \"last seen plain secret\" trick for migration",[11,456,457,458,460,461,465],{},"Passport v13 includes a migration helper: a ",[37,459,39],{}," column can hold either the hash ",[462,463,464],"em",{},"or"," the plain-text secret. On first successful authentication with a plain secret, Passport rewrites the column with the hash.",[11,467,468,469,472],{},"To enable this hybrid mode, make sure ",[37,470,471],{},"hashClientSecrets()"," is on (the default) and check your Passport version is v13.2 or later — the automatic rehash was added in a patch release, not the initial v13.0.",[11,474,475],{},"This means if you upgrade, leave hashing on, and don't force-rotate, the secrets get hashed progressively as clients authenticate. After a week or two of normal traffic, the vast majority of your clients will have migrated silently.",[11,477,478],{},"The caveat: any client that doesn't authenticate during that window stays as plain-text. You still need a cleanup pass eventually.",[18,480,482],{"id":481},"the-storage-cast-caveat","The storage cast caveat",[11,484,485,486,489],{},"If you were previously using the encrypted casting feature for secrets (",[37,487,488],{},"Passport::storeClientSecretsEncrypted()"," in earlier versions), note that v13 changed the cast behaviour. Specifically:",[124,491,493],{"className":126,"code":492,"language":128,"meta":129,"style":129},"// In Passport's Client model (v13+)\nprotected function casts(): array\n{\n    return [\n        'secret' => 'hashed',  // uses Laravel's built-in Hashed cast\n        // ...\n    ];\n}\n",[37,494,495,500,517,521,529,546,551,556],{"__ignoreMap":129},[133,496,497],{"class":135,"line":136},[133,498,499],{"class":188},"// In Passport's Client model (v13+)\n",[133,501,502,505,507,510,512,514],{"class":135,"line":151},[133,503,504],{"class":139},"protected",[133,506,164],{"class":139},[133,508,509],{"class":167}," casts",[133,511,171],{"class":147},[133,513,122],{"class":139},[133,515,516],{"class":139}," array\n",[133,518,519],{"class":135,"line":158},[133,520,182],{"class":147},[133,522,523,526],{"class":135,"line":179},[133,524,525],{"class":139},"    return",[133,527,528],{"class":147}," [\n",[133,530,531,534,537,540,543],{"class":135,"line":185},[133,532,533],{"class":414},"        'secret'",[133,535,536],{"class":139}," =>",[133,538,539],{"class":414}," 'hashed'",[133,541,542],{"class":147},",  ",[133,544,545],{"class":188},"// uses Laravel's built-in Hashed cast\n",[133,547,548],{"class":135,"line":192},[133,549,550],{"class":188},"        // ...\n",[133,552,553],{"class":135,"line":213},[133,554,555],{"class":147},"    ];\n",[133,557,558],{"class":135,"line":341},[133,559,216],{"class":147},[11,561,562,563,566],{},"The ",[37,564,565],{},"hashed"," cast is write-only — it hashes on set, but on read you get the hashed value, not the plain one. Which means you can't look up a secret after creation. If your team was doing that (\"what's the secret for the reporting integration again?\"), that workflow is dead. The new pattern is: generate the secret, show it once, and if it's lost, rotate.",[11,568,569],{},"This is the same pattern Laravel uses for API tokens in Sanctum and for password resets. It's a nuisance until you internalise it, then it's obviously correct.",[18,571,573],{"id":572},"the-invalid_client-error-is-misleading","The \"invalid_client\" error is misleading",[11,575,576,577,580,581,584,585,588],{},"One final trap worth flagging. When the secret comparison fails, the OAuth server returns ",[37,578,579],{},"invalid_client",". This is the correct OAuth 2.0 error, but it's phrased as if ",[462,582,583],{},"the client itself"," is wrong — as if the ",[37,586,587],{},"client_id"," doesn't exist. That's what sent me down a rabbit hole initially.",[11,590,591,592,594],{},"If you see ",[37,593,579],{}," after upgrading Passport:",[221,596,597,603,609],{},[29,598,599,600,602],{},"First check: does the client ID exist in ",[37,601,43],{},"? (usually yes)",[29,604,605,606,608],{},"Second check: is ",[37,607,39],{}," column populated? (usually yes)",[29,610,611,612,614,615,618],{},"Third check: is the value in ",[37,613,39],{}," a bcrypt hash starting with ",[37,616,617],{},"$2y$",", or is it the plain text? (this is your answer)",[11,620,621],{},"If it's plain text, apply one of the three migration options above. If it's a hash and auth is still failing, the client has the wrong secret — rotate it.",[18,623,625],{"id":624},"the-takeaway","The takeaway",[11,627,628],{},"The upgrade itself is easy — the config is straightforward, the change is well-motivated, and the framework's new default is genuinely more secure. The rough part is the migration of existing data, which has no automatic path.",[11,630,631,632,635],{},"Before upgrading Passport in production, count your OAuth clients, identify who owns each one, and pick a migration option. Don't just run ",[37,633,634],{},"composer update"," and hope. That's the class of mistake that ends with your team in a Slack call at 11pm explaining to a partner why their webhook integration stopped working.",[637,638,639],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":129,"searchDepth":158,"depth":158,"links":641},[642,643,644,649,650,651],{"id":20,"depth":151,"text":21},{"id":63,"depth":151,"text":64},{"id":106,"depth":151,"text":107,"children":645},[646,647,648],{"id":114,"depth":158,"text":115},{"id":251,"depth":158,"text":252},{"id":453,"depth":158,"text":454},{"id":481,"depth":151,"text":482},{"id":572,"depth":151,"text":573},{"id":624,"depth":151,"text":625},"debugging","Passport v13 changed how client secrets are stored. If you upgrade without reading the changelog, existing OAuth consumers will quietly stop working.",false,"md",{},"https://digifellow.co.za/og/passport-v13.png","/blog/passport-v13-secrets","2026-01-10","5",{"title":5,"description":653},"blog/passport-v13-secrets",[664,665,666,667],"passport","oauth","laravel","upgrades","lJxhAOeXR9rexFlihG19cs0ZQZK-WA52oYndtZyFaJY",[670,1533],{"id":671,"title":672,"author":6,"body":673,"category":652,"description":1521,"draft":654,"extension":655,"featured":154,"meta":1522,"navigation":154,"ogImage":1523,"path":1524,"publishedAt":1525,"readingTime":1526,"seo":1527,"stem":1528,"tags":1529,"updatedAt":1525,"__hash__":1532},"blog/blog/horizon-queue-name-mismatch.md","The silent Horizon queue failure that cost us a full day of jobs",{"type":8,"value":674,"toc":1510},[675,682,685,688,692,695,725,728,732,735,756,759,774,781,785,789,792,822,836,894,900,904,907,1000,1003,1022,1033,1037,1040,1137,1144,1161,1164,1267,1270,1297,1301,1311,1314,1328,1334,1401,1404,1486,1489,1495,1497,1500,1507],[11,676,677,678,681],{},"There's a particular class of Laravel Horizon bug that's dangerous precisely because everything looks fine. Horizon's dashboard shows green. Your workers are running. ",[37,679,680],{},"supervisord"," is happy. Your logs are empty. And yet somehow, over the course of eight hours, zero jobs have actually been processed.",[11,683,684],{},"The culprit is almost always the same: your application is dispatching jobs to one queue name, and your Horizon workers are listening on a different one. Nothing fails — the jobs just sit in Redis, piling up, while Horizon cheerfully reports success on a queue nobody's using.",[11,686,687],{},"This post walks through how to spot the bug, why it happens, and — most importantly — how Redis cluster curly-brace queue names turn it from an occasional nuisance into something that can quietly kill jobs for hours.",[18,689,691],{"id":690},"how-the-failure-actually-looks","How the failure actually looks",[11,693,694],{},"The classic symptoms:",[26,696,697,700,709,712,715,718],{},[29,698,699],{},"Horizon dashboard shows workers as \"running\", throughput graph is flat or near-zero",[29,701,702,705,706],{},[37,703,704],{},"php artisan horizon:status"," returns ",[37,707,708],{},"running",[29,710,711],{},"Your application dispatches jobs without errors",[29,713,714],{},"Users report that background work (emails, reports, exports) never completed",[29,716,717],{},"Queue length in Redis is growing, not shrinking",[29,719,720,721,724],{},"No entries in ",[37,722,723],{},"failed_jobs"," — because jobs aren't failing, they're just never being picked up",[11,726,727],{},"The worst part is that your tests pass. In CI, you typically run jobs synchronously or through the array driver, so the queue name mismatch never shows up until production.",[18,729,731],{"id":730},"the-fastest-diagnostic","The fastest diagnostic",[11,733,734],{},"Before you touch any config, run this against your production Redis:",[124,736,740],{"className":737,"code":738,"language":739,"meta":129,"style":129},"language-bash shiki shiki-themes github-dark","redis-cli --scan --pattern 'queues:*'\n","bash",[37,741,742],{"__ignoreMap":129},[133,743,744,747,750,753],{"class":135,"line":136},[133,745,746],{"class":167},"redis-cli",[133,748,749],{"class":143}," --scan",[133,751,752],{"class":143}," --pattern",[133,754,755],{"class":414}," 'queues:*'\n",[11,757,758],{},"You'll see every queue Laravel has pushed work into. Compare that output against what Horizon is configured to listen on:",[124,760,762],{"className":737,"code":761,"language":739,"meta":129,"style":129},"php artisan horizon:list\n",[37,763,764],{"__ignoreMap":129},[133,765,766,768,771],{"class":135,"line":136},[133,767,128],{"class":167},[133,769,770],{"class":414}," artisan",[133,772,773],{"class":414}," horizon:list\n",[11,775,776,777,780],{},"If the queue names in the first list don't match the queue names in the second, you've found your bug. Now you just need to understand ",[462,778,779],{},"why"," they don't match.",[18,782,784],{"id":783},"the-three-common-causes","The three common causes",[112,786,788],{"id":787},"_1-dispatching-to-a-queue-name-that-doesnt-exist-in-horizon-config","1. Dispatching to a queue name that doesn't exist in Horizon config",[11,790,791],{},"The simplest case. Somewhere in your code you have:",[124,793,795],{"className":126,"code":794,"language":128,"meta":129,"style":129},"ProcessReport::dispatch($report)->onQueue('reports');\n",[37,796,797],{"__ignoreMap":129},[133,798,799,802,804,807,810,812,815,817,820],{"class":135,"line":136},[133,800,801],{"class":143},"ProcessReport",[133,803,198],{"class":139},[133,805,806],{"class":167},"dispatch",[133,808,809],{"class":147},"($report)",[133,811,370],{"class":139},[133,813,814],{"class":167},"onQueue",[133,816,204],{"class":147},[133,818,819],{"class":414},"'reports'",[133,821,210],{"class":147},[11,823,824,825,828,829,832,833,122],{},"But your ",[37,826,827],{},"config/horizon.php"," only lists ",[37,830,831],{},"default"," and ",[37,834,835],{},"notifications",[124,837,839],{"className":126,"code":838,"language":128,"meta":129,"style":129},"'defaults' => [\n    'supervisor-1' => [\n        'queue' => ['default', 'notifications'],\n        // ...\n    ],\n],\n",[37,840,841,850,859,881,885,890],{"__ignoreMap":129},[133,842,843,846,848],{"class":135,"line":136},[133,844,845],{"class":414},"'defaults'",[133,847,536],{"class":139},[133,849,528],{"class":147},[133,851,852,855,857],{"class":135,"line":151},[133,853,854],{"class":414},"    'supervisor-1'",[133,856,536],{"class":139},[133,858,528],{"class":147},[133,860,861,864,866,869,872,875,878],{"class":135,"line":158},[133,862,863],{"class":414},"        'queue'",[133,865,536],{"class":139},[133,867,868],{"class":147}," [",[133,870,871],{"class":414},"'default'",[133,873,874],{"class":147},", ",[133,876,877],{"class":414},"'notifications'",[133,879,880],{"class":147},"],\n",[133,882,883],{"class":135,"line":179},[133,884,550],{"class":188},[133,886,887],{"class":135,"line":185},[133,888,889],{"class":147},"    ],\n",[133,891,892],{"class":135,"line":192},[133,893,880],{"class":147},[11,895,562,896,899],{},[37,897,898],{},"reports"," queue gets populated in Redis, but no worker is watching it. Fix: add it to the Horizon supervisor's queue list.",[112,901,903],{"id":902},"_2-environment-specific-queue-names","2. Environment-specific queue names",[11,905,906],{},"Perhaps the most common version in multi-environment apps. You prefix queues per environment to keep them isolated:",[124,908,910],{"className":126,"code":909,"language":128,"meta":129,"style":129},"// In a job\npublic function viaConnection(): string\n{\n    return 'redis';\n}\n\npublic function viaQueue(): string\n{\n    return config('app.env') . '-emails';\n}\n",[37,911,912,917,933,937,946,950,954,969,973,996],{"__ignoreMap":129},[133,913,914],{"class":135,"line":136},[133,915,916],{"class":188},"// In a job\n",[133,918,919,921,923,926,928,930],{"class":135,"line":151},[133,920,161],{"class":139},[133,922,164],{"class":139},[133,924,925],{"class":167}," viaConnection",[133,927,171],{"class":147},[133,929,122],{"class":139},[133,931,932],{"class":139}," string\n",[133,934,935],{"class":135,"line":158},[133,936,182],{"class":147},[133,938,939,941,944],{"class":135,"line":179},[133,940,525],{"class":139},[133,942,943],{"class":414}," 'redis'",[133,945,148],{"class":147},[133,947,948],{"class":135,"line":185},[133,949,216],{"class":147},[133,951,952],{"class":135,"line":192},[133,953,155],{"emptyLinePlaceholder":154},[133,955,956,958,960,963,965,967],{"class":135,"line":213},[133,957,161],{"class":139},[133,959,164],{"class":139},[133,961,962],{"class":167}," viaQueue",[133,964,171],{"class":147},[133,966,122],{"class":139},[133,968,932],{"class":139},[133,970,971],{"class":135,"line":341},[133,972,182],{"class":147},[133,974,975,977,980,982,985,988,991,994],{"class":135,"line":364},[133,976,525],{"class":139},[133,978,979],{"class":167}," config",[133,981,204],{"class":147},[133,983,984],{"class":414},"'app.env'",[133,986,987],{"class":147},") ",[133,989,990],{"class":139},".",[133,992,993],{"class":414}," '-emails'",[133,995,148],{"class":147},[133,997,998],{"class":135,"line":384},[133,999,216],{"class":147},[11,1001,1002],{},"But in Horizon config:",[124,1004,1006],{"className":126,"code":1005,"language":128,"meta":129,"style":129},"'queue' => ['production-emails'],\n",[37,1007,1008],{"__ignoreMap":129},[133,1009,1010,1013,1015,1017,1020],{"class":135,"line":136},[133,1011,1012],{"class":414},"'queue'",[133,1014,536],{"class":139},[133,1016,868],{"class":147},[133,1018,1019],{"class":414},"'production-emails'",[133,1021,880],{"class":147},[11,1023,1024,1025,1028,1029,1032],{},"Staging dispatches to ",[37,1026,1027],{},"staging-emails",", Horizon on staging listens for ",[37,1030,1031],{},"production-emails",". Jobs pile up forever. This one bites hard on clone-from-production setups where someone forgot to environment-aware the Horizon config.",[112,1034,1036],{"id":1035},"_3-redis-cluster-curly-brace-naming-the-sneaky-one","3. Redis cluster curly-brace naming (the sneaky one)",[11,1038,1039],{},"This is the version that cost me a full day. If you're running Redis in cluster mode, Laravel requires queue names to use curly-brace \"hash tags\" so all related keys land on the same node:",[124,1041,1043],{"className":126,"code":1042,"language":128,"meta":129,"style":129},"// config/queue.php\n'redis' => [\n    'driver' => 'redis',\n    'connection' => 'default',\n    'queue' => env('REDIS_QUEUE', '{default}'),  // \u003C-- note the braces\n    'retry_after' => 90,\n    'block_for' => null,\n],\n",[37,1044,1045,1050,1059,1071,1083,1109,1121,1133],{"__ignoreMap":129},[133,1046,1047],{"class":135,"line":136},[133,1048,1049],{"class":188},"// config/queue.php\n",[133,1051,1052,1055,1057],{"class":135,"line":151},[133,1053,1054],{"class":414},"'redis'",[133,1056,536],{"class":139},[133,1058,528],{"class":147},[133,1060,1061,1064,1066,1068],{"class":135,"line":158},[133,1062,1063],{"class":414},"    'driver'",[133,1065,536],{"class":139},[133,1067,943],{"class":414},[133,1069,1070],{"class":147},",\n",[133,1072,1073,1076,1078,1081],{"class":135,"line":179},[133,1074,1075],{"class":414},"    'connection'",[133,1077,536],{"class":139},[133,1079,1080],{"class":414}," 'default'",[133,1082,1070],{"class":147},[133,1084,1085,1088,1090,1093,1095,1098,1100,1103,1106],{"class":135,"line":185},[133,1086,1087],{"class":414},"    'queue'",[133,1089,536],{"class":139},[133,1091,1092],{"class":167}," env",[133,1094,204],{"class":147},[133,1096,1097],{"class":414},"'REDIS_QUEUE'",[133,1099,874],{"class":147},[133,1101,1102],{"class":414},"'{default}'",[133,1104,1105],{"class":147},"),  ",[133,1107,1108],{"class":188},"// \u003C-- note the braces\n",[133,1110,1111,1114,1116,1119],{"class":135,"line":192},[133,1112,1113],{"class":414},"    'retry_after'",[133,1115,536],{"class":139},[133,1117,1118],{"class":143}," 90",[133,1120,1070],{"class":147},[133,1122,1123,1126,1128,1131],{"class":135,"line":213},[133,1124,1125],{"class":414},"    'block_for'",[133,1127,536],{"class":139},[133,1129,1130],{"class":143}," null",[133,1132,1070],{"class":147},[133,1134,1135],{"class":135,"line":341},[133,1136,880],{"class":147},[11,1138,1139,1140,1143],{},"The braces tell Redis cluster \"hash only the part inside ",[37,1141,1142],{},"{}"," when deciding which node holds this key\". Without them, the main queue key, the reserved set, the delayed set, and the notifications key can all land on different nodes — and job coordination falls apart.",[11,1145,1146,1149,1150,1153,1154,1157,1158,1160],{},[32,1147,1148],{},"The trap:"," this naming must be consistent ",[462,1151,1152],{},"everywhere",". If your application dispatches to ",[37,1155,1156],{},"{default}"," but your Horizon config lists ",[37,1159,831],{}," (no braces), they are different queues as far as Redis is concerned. And because Horizon doesn't validate that its configured queues actually match real Redis keys, it'll happily report as running while listening on a queue that will never receive work.",[11,1162,1163],{},"The fix is boringly mechanical — make every reference use the same bracketed form:",[124,1165,1167],{"className":126,"code":1166,"language":128,"meta":129,"style":129},"// config/horizon.php\n'defaults' => [\n    'supervisor-1' => [\n        'connection' => 'redis',\n        'queue' => ['{default}', '{notifications}', '{emails}'],\n        'balance' => 'auto',\n        'processes' => 10,\n        'tries' => 3,\n    ],\n],\n",[37,1168,1169,1174,1182,1190,1201,1223,1235,1247,1259,1263],{"__ignoreMap":129},[133,1170,1171],{"class":135,"line":136},[133,1172,1173],{"class":188},"// config/horizon.php\n",[133,1175,1176,1178,1180],{"class":135,"line":151},[133,1177,845],{"class":414},[133,1179,536],{"class":139},[133,1181,528],{"class":147},[133,1183,1184,1186,1188],{"class":135,"line":158},[133,1185,854],{"class":414},[133,1187,536],{"class":139},[133,1189,528],{"class":147},[133,1191,1192,1195,1197,1199],{"class":135,"line":179},[133,1193,1194],{"class":414},"        'connection'",[133,1196,536],{"class":139},[133,1198,943],{"class":414},[133,1200,1070],{"class":147},[133,1202,1203,1205,1207,1209,1211,1213,1216,1218,1221],{"class":135,"line":185},[133,1204,863],{"class":414},[133,1206,536],{"class":139},[133,1208,868],{"class":147},[133,1210,1102],{"class":414},[133,1212,874],{"class":147},[133,1214,1215],{"class":414},"'{notifications}'",[133,1217,874],{"class":147},[133,1219,1220],{"class":414},"'{emails}'",[133,1222,880],{"class":147},[133,1224,1225,1228,1230,1233],{"class":135,"line":192},[133,1226,1227],{"class":414},"        'balance'",[133,1229,536],{"class":139},[133,1231,1232],{"class":414}," 'auto'",[133,1234,1070],{"class":147},[133,1236,1237,1240,1242,1245],{"class":135,"line":213},[133,1238,1239],{"class":414},"        'processes'",[133,1241,536],{"class":139},[133,1243,1244],{"class":143}," 10",[133,1246,1070],{"class":147},[133,1248,1249,1252,1254,1257],{"class":135,"line":341},[133,1250,1251],{"class":414},"        'tries'",[133,1253,536],{"class":139},[133,1255,1256],{"class":143}," 3",[133,1258,1070],{"class":147},[133,1260,1261],{"class":135,"line":364},[133,1262,889],{"class":147},[133,1264,1265],{"class":135,"line":384},[133,1266,880],{"class":147},[11,1268,1269],{},"And in any place you dispatch with an explicit queue name:",[124,1271,1273],{"className":126,"code":1272,"language":128,"meta":129,"style":129},"SendReport::dispatch($report)->onQueue('{reports}');\n",[37,1274,1275],{"__ignoreMap":129},[133,1276,1277,1280,1282,1284,1286,1288,1290,1292,1295],{"class":135,"line":136},[133,1278,1279],{"class":143},"SendReport",[133,1281,198],{"class":139},[133,1283,806],{"class":167},[133,1285,809],{"class":147},[133,1287,370],{"class":139},[133,1289,814],{"class":167},[133,1291,204],{"class":147},[133,1293,1294],{"class":414},"'{reports}'",[133,1296,210],{"class":147},[18,1298,1300],{"id":1299},"monitoring-so-this-never-happens-again","Monitoring so this never happens again",[11,1302,1303,1304,1307,1308,990],{},"The core lesson I took from this: Horizon's own dashboard is not enough to tell you whether jobs are actually being processed. The dashboard tells you about ",[462,1305,1306],{},"workers",", not about ",[462,1309,1310],{},"work",[11,1312,1313],{},"Three pieces of monitoring I now add to every project:",[11,1315,1316,1319,1320,1323,1324,1327],{},[32,1317,1318],{},"1. Queue depth alerts."," Use the ",[37,1321,1322],{},"laravel-horizon-prometheus-exporter"," package or a custom metric that reports ",[37,1325,1326],{},"Redis::llen('queues:{default}')"," to your monitoring system. Alert if any queue exceeds a sensible threshold (e.g., 500 jobs for default, 50 for high-priority queues). If the queue is full and Horizon says everything's fine, you've found the mismatch fast.",[11,1329,1330,1333],{},[32,1331,1332],{},"2. Synthetic job canary."," Schedule a tiny job every minute that just writes a timestamp to a Redis key:",[124,1335,1337],{"className":126,"code":1336,"language":128,"meta":129,"style":129},"// app/Console/Kernel.php\nprotected function schedule(Schedule $schedule): void\n{\n    $schedule->job(new QueueCanaryJob())->everyMinute();\n}\n",[37,1338,1339,1344,1365,1369,1397],{"__ignoreMap":129},[133,1340,1341],{"class":135,"line":136},[133,1342,1343],{"class":188},"// app/Console/Kernel.php\n",[133,1345,1346,1348,1350,1353,1355,1358,1361,1363],{"class":135,"line":151},[133,1347,504],{"class":139},[133,1349,164],{"class":139},[133,1351,1352],{"class":167}," schedule",[133,1354,204],{"class":147},[133,1356,1357],{"class":143},"Schedule",[133,1359,1360],{"class":147}," $schedule)",[133,1362,122],{"class":139},[133,1364,176],{"class":139},[133,1366,1367],{"class":135,"line":158},[133,1368,182],{"class":147},[133,1370,1371,1374,1376,1379,1381,1384,1387,1390,1392,1395],{"class":135,"line":179},[133,1372,1373],{"class":147},"    $schedule",[133,1375,370],{"class":139},[133,1377,1378],{"class":167},"job",[133,1380,204],{"class":147},[133,1382,1383],{"class":139},"new",[133,1385,1386],{"class":143}," QueueCanaryJob",[133,1388,1389],{"class":147},"())",[133,1391,370],{"class":139},[133,1393,1394],{"class":167},"everyMinute",[133,1396,320],{"class":147},[133,1398,1399],{"class":135,"line":185},[133,1400,216],{"class":147},[11,1402,1403],{},"The job itself:",[124,1405,1407],{"className":126,"code":1406,"language":128,"meta":129,"style":129},"class QueueCanaryJob implements ShouldQueue\n{\n    public function handle(): void\n    {\n        Redis::set('queue:canary:last_run', now()->toIso8601String());\n    }\n}\n",[37,1408,1409,1422,1426,1442,1447,1477,1482],{"__ignoreMap":129},[133,1410,1411,1414,1416,1419],{"class":135,"line":136},[133,1412,1413],{"class":139},"class",[133,1415,1386],{"class":167},[133,1417,1418],{"class":139}," implements",[133,1420,1421],{"class":167}," ShouldQueue\n",[133,1423,1424],{"class":135,"line":151},[133,1425,182],{"class":147},[133,1427,1428,1431,1433,1436,1438,1440],{"class":135,"line":158},[133,1429,1430],{"class":139},"    public",[133,1432,164],{"class":139},[133,1434,1435],{"class":167}," handle",[133,1437,171],{"class":147},[133,1439,122],{"class":139},[133,1441,176],{"class":139},[133,1443,1444],{"class":135,"line":179},[133,1445,1446],{"class":147},"    {\n",[133,1448,1449,1452,1454,1457,1459,1462,1464,1467,1469,1471,1474],{"class":135,"line":185},[133,1450,1451],{"class":143},"        Redis",[133,1453,198],{"class":139},[133,1455,1456],{"class":167},"set",[133,1458,204],{"class":147},[133,1460,1461],{"class":414},"'queue:canary:last_run'",[133,1463,874],{"class":147},[133,1465,1466],{"class":167},"now",[133,1468,171],{"class":147},[133,1470,370],{"class":139},[133,1472,1473],{"class":167},"toIso8601String",[133,1475,1476],{"class":147},"());\n",[133,1478,1479],{"class":135,"line":192},[133,1480,1481],{"class":147},"    }\n",[133,1483,1484],{"class":135,"line":213},[133,1485,216],{"class":147},[11,1487,1488],{},"Then have your uptime monitoring check that the value is less than, say, 5 minutes old. If the canary stops updating, you know Horizon isn't processing work regardless of what the dashboard says.",[11,1490,1491,1494],{},[32,1492,1493],{},"3. Job throughput SLO."," If you normally process hundreds of jobs per hour, a dashboard showing zero throughput for 30 minutes should page you. Horizon exposes throughput via its metrics endpoint; plug it into whatever alerting you use.",[18,1496,625],{"id":624},[11,1498,1499],{},"Horizon's biggest strength — its hands-off, \"just works\" operational model — is also the reason this bug class is so painful. The abstraction is so clean that when the connection between \"dispatcher\" and \"worker\" silently breaks, there's no natural place to notice.",[11,1501,1502,1503,1506],{},"If you take one thing from this post: ",[32,1504,1505],{},"don't trust the dashboard alone",". Measure the actual flow of work, not the health of the workers. A healthy worker listening on the wrong queue is the same as no worker at all.",[637,1508,1509],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#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 pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":129,"searchDepth":158,"depth":158,"links":1511},[1512,1513,1514,1519,1520],{"id":690,"depth":151,"text":691},{"id":730,"depth":151,"text":731},{"id":783,"depth":151,"text":784,"children":1515},[1516,1517,1518],{"id":787,"depth":158,"text":788},{"id":902,"depth":158,"text":903},{"id":1035,"depth":158,"text":1036},{"id":1299,"depth":151,"text":1300},{"id":624,"depth":151,"text":625},"A Laravel Horizon deploy that looks healthy in every dashboard but processes zero real work — and the Redis curly-brace queue naming quirk behind it.",{},"https://digifellow.co.za/og/horizon-queue-failure.png","/blog/horizon-queue-name-mismatch","2026-03-15","4",{"title":672,"description":1521},"blog/horizon-queue-name-mismatch",[666,1530,1531],"horizon","redis","JYfE7naBvkrPg_EXOOPwBLuZuoZ5LzvFzR2Jf-qYXLM",{"id":1534,"title":1535,"author":6,"body":1536,"category":652,"description":2457,"draft":654,"extension":655,"featured":654,"meta":2458,"navigation":154,"ogImage":2459,"path":2460,"publishedAt":2461,"readingTime":1526,"seo":2462,"stem":2463,"tags":2464,"updatedAt":2461,"__hash__":2466},"blog/blog/inertia-onunmounted.md","onUnmounted isn't firing in my Inertia.js app — here's why",{"type":8,"value":1537,"toc":2443},[1538,1549,1555,1559,1562,1579,1589,1607,1611,1614,1631,1634,1638,1641,1649,1655,1659,1674,1987,1999,2005,2013,2016,2294,2297,2362,2368,2372,2382,2407,2410,2414,2427,2430,2432,2437,2440],[11,1539,1540,1541,1544,1545,1548],{},"You set up an interval in ",[37,1542,1543],{},"onMounted",", clear it in ",[37,1546,1547],{},"onUnmounted",", and move on. Standard Vue. Then a user reports that your dashboard is \"kind of slow and my laptop fan is spinning\" after they've navigated around the app for a while. You open DevTools and discover that your polling function is running not once but seven times per second, because every time they visited the page a new interval started and none of the old ones got cleared.",[11,1550,1551,1552,1554],{},"Your ",[37,1553,1547],{}," never fired. Welcome to one of Inertia.js's stickier behaviours.",[18,1556,1558],{"id":1557},"whats-actually-happening","What's actually happening",[11,1560,1561],{},"Inertia isn't a full SPA framework — it's a thin layer that lets Laravel return something that looks like a Vue page instead of HTML. When you navigate in an Inertia app, here's what happens at the Vue lifecycle level:",[221,1563,1564,1567,1570,1576],{},[29,1565,1566],{},"The user clicks a link",[29,1568,1569],{},"Inertia fetches the new page's props from Laravel",[29,1571,562,1572,1575],{},[32,1573,1574],{},"same page component instance"," may be reused if the next page is the same component type",[29,1577,1578],{},"Only the props change; Vue treats it as a reactive update",[11,1580,1581,1582,1585,1586,1588],{},"This is great for performance — no teardown-and-rebuild cycle between visits to the same component. But it means that when you navigate from one page to another rendered by the ",[462,1583,1584],{},"same"," Vue component (or when Inertia decides to preserve an instance), ",[37,1587,1547],{}," does not fire, because the component is not actually unmounted.",[11,1590,1591,1592,1595,1596,1599,1600,1603,1604,1606],{},"The classic case: you have a ",[37,1593,1594],{},"Dashboard.vue"," page. You navigate from ",[37,1597,1598],{},"/dashboard/team-a"," to ",[37,1601,1602],{},"/dashboard/team-b",". Both URLs render the ",[37,1605,1594],{}," component. Inertia keeps the instance, swaps the props, and calls it a day. Your mounted hook runs once (on the first visit), sets up an interval, and your unmounted hook never fires because the component is still mounted — you just navigated between two views that happen to share it.",[18,1608,1610],{"id":1609},"the-symptoms","The symptoms",[11,1612,1613],{},"A few things you'll typically see:",[26,1615,1616,1619,1622,1625,1628],{},[29,1617,1618],{},"Intervals, timeouts, or event listeners that accumulate with every page visit",[29,1620,1621],{},"Memory usage growing the longer the user stays in the app",[29,1623,1624],{},"WebSocket connections that should have closed but haven't",[29,1626,1627],{},"Keyboard shortcuts from an old page still firing on a new page",[29,1629,1630],{},"API polling that starts running at absurd frequencies",[11,1632,1633],{},"If you've seen any of these in an Inertia app and blamed it on \"Vue weirdness\", this is probably the real cause.",[18,1635,1637],{"id":1636},"the-fix","The fix",[11,1639,1640],{},"You have three options, in increasing order of framework-awareness.",[112,1642,1644,1645,1648],{"id":1643},"option-1-use-onbeforeunmount-still-doesnt-help","Option 1: Use ",[37,1646,1647],{},"onBeforeUnmount"," — still doesn't help",[11,1650,1651,1652,1654],{},"Just to get this out of the way: ",[37,1653,1647],{}," has the same problem. Neither unmount hook fires if the component isn't actually being unmounted.",[112,1656,1658],{"id":1657},"option-2-use-inertias-router-events","Option 2: Use Inertia's router events",[11,1660,1661,1662,1665,1666,1669,1670,1673],{},"Inertia exposes router events that ",[462,1663,1664],{},"do"," fire on every navigation, including navigations that preserve the current component. The one you want is ",[37,1667,1668],{},"router.on('before', ...)"," for cleanup before leaving, or ",[37,1671,1672],{},"router.on('navigate', ...)"," for reacting after arrival.",[124,1675,1679],{"className":1676,"code":1677,"language":1678,"meta":129,"style":129},"language-vue shiki shiki-themes github-dark","\u003Cscript setup>\nimport { onMounted, onUnmounted } from 'vue'\nimport { router } from '@inertiajs/vue3'\n\nlet pollInterval = null\nlet removeBeforeListener = null\n\nfunction startPolling() {\n  pollInterval = setInterval(() => {\n    // ... your polling logic\n  }, 5000)\n}\n\nfunction stopPolling() {\n  if (pollInterval) {\n    clearInterval(pollInterval)\n    pollInterval = null\n  }\n}\n\nonMounted(() => {\n  startPolling()\n\n  // Listen for Inertia navigations, not just Vue unmounts\n  removeBeforeListener = router.on('before', () => {\n    stopPolling()\n  })\n})\n\nonUnmounted(() => {\n  stopPolling()\n  removeBeforeListener?.()\n})\n\u003C/script>\n","vue",[37,1680,1681,1696,1710,1722,1726,1739,1750,1754,1765,1784,1789,1800,1804,1808,1818,1827,1836,1846,1852,1857,1862,1873,1882,1887,1893,1919,1927,1933,1939,1944,1955,1963,1972,1977],{"__ignoreMap":129},[133,1682,1683,1686,1690,1693],{"class":135,"line":136},[133,1684,1685],{"class":147},"\u003C",[133,1687,1689],{"class":1688},"s4JwU","script",[133,1691,1692],{"class":167}," setup",[133,1694,1695],{"class":147},">\n",[133,1697,1698,1701,1704,1707],{"class":135,"line":151},[133,1699,1700],{"class":139},"import",[133,1702,1703],{"class":147}," { onMounted, onUnmounted } ",[133,1705,1706],{"class":139},"from",[133,1708,1709],{"class":414}," 'vue'\n",[133,1711,1712,1714,1717,1719],{"class":135,"line":158},[133,1713,1700],{"class":139},[133,1715,1716],{"class":147}," { router } ",[133,1718,1706],{"class":139},[133,1720,1721],{"class":414}," '@inertiajs/vue3'\n",[133,1723,1724],{"class":135,"line":179},[133,1725,155],{"emptyLinePlaceholder":154},[133,1727,1728,1731,1734,1736],{"class":135,"line":185},[133,1729,1730],{"class":139},"let",[133,1732,1733],{"class":147}," pollInterval ",[133,1735,309],{"class":139},[133,1737,1738],{"class":143}," null\n",[133,1740,1741,1743,1746,1748],{"class":135,"line":192},[133,1742,1730],{"class":139},[133,1744,1745],{"class":147}," removeBeforeListener ",[133,1747,309],{"class":139},[133,1749,1738],{"class":143},[133,1751,1752],{"class":135,"line":213},[133,1753,155],{"emptyLinePlaceholder":154},[133,1755,1756,1759,1762],{"class":135,"line":341},[133,1757,1758],{"class":139},"function",[133,1760,1761],{"class":167}," startPolling",[133,1763,1764],{"class":147},"() {\n",[133,1766,1767,1770,1772,1775,1778,1781],{"class":135,"line":364},[133,1768,1769],{"class":147},"  pollInterval ",[133,1771,309],{"class":139},[133,1773,1774],{"class":167}," setInterval",[133,1776,1777],{"class":147},"(() ",[133,1779,1780],{"class":139},"=>",[133,1782,1783],{"class":147}," {\n",[133,1785,1786],{"class":135,"line":384},[133,1787,1788],{"class":188},"    // ... your polling logic\n",[133,1790,1791,1794,1797],{"class":135,"line":396},[133,1792,1793],{"class":147},"  }, ",[133,1795,1796],{"class":143},"5000",[133,1798,1799],{"class":147},")\n",[133,1801,1802],{"class":135,"line":401},[133,1803,216],{"class":147},[133,1805,1806],{"class":135,"line":437},[133,1807,155],{"emptyLinePlaceholder":154},[133,1809,1811,1813,1816],{"class":135,"line":1810},14,[133,1812,1758],{"class":139},[133,1814,1815],{"class":167}," stopPolling",[133,1817,1764],{"class":147},[133,1819,1821,1824],{"class":135,"line":1820},15,[133,1822,1823],{"class":139},"  if",[133,1825,1826],{"class":147}," (pollInterval) {\n",[133,1828,1830,1833],{"class":135,"line":1829},16,[133,1831,1832],{"class":167},"    clearInterval",[133,1834,1835],{"class":147},"(pollInterval)\n",[133,1837,1839,1842,1844],{"class":135,"line":1838},17,[133,1840,1841],{"class":147},"    pollInterval ",[133,1843,309],{"class":139},[133,1845,1738],{"class":143},[133,1847,1849],{"class":135,"line":1848},18,[133,1850,1851],{"class":147},"  }\n",[133,1853,1855],{"class":135,"line":1854},19,[133,1856,216],{"class":147},[133,1858,1860],{"class":135,"line":1859},20,[133,1861,155],{"emptyLinePlaceholder":154},[133,1863,1865,1867,1869,1871],{"class":135,"line":1864},21,[133,1866,1543],{"class":167},[133,1868,1777],{"class":147},[133,1870,1780],{"class":139},[133,1872,1783],{"class":147},[133,1874,1876,1879],{"class":135,"line":1875},22,[133,1877,1878],{"class":167},"  startPolling",[133,1880,1881],{"class":147},"()\n",[133,1883,1885],{"class":135,"line":1884},23,[133,1886,155],{"emptyLinePlaceholder":154},[133,1888,1890],{"class":135,"line":1889},24,[133,1891,1892],{"class":188},"  // Listen for Inertia navigations, not just Vue unmounts\n",[133,1894,1896,1899,1901,1904,1907,1909,1912,1915,1917],{"class":135,"line":1895},25,[133,1897,1898],{"class":147},"  removeBeforeListener ",[133,1900,309],{"class":139},[133,1902,1903],{"class":147}," router.",[133,1905,1906],{"class":167},"on",[133,1908,204],{"class":147},[133,1910,1911],{"class":414},"'before'",[133,1913,1914],{"class":147},", () ",[133,1916,1780],{"class":139},[133,1918,1783],{"class":147},[133,1920,1922,1925],{"class":135,"line":1921},26,[133,1923,1924],{"class":167},"    stopPolling",[133,1926,1881],{"class":147},[133,1928,1930],{"class":135,"line":1929},27,[133,1931,1932],{"class":147},"  })\n",[133,1934,1936],{"class":135,"line":1935},28,[133,1937,1938],{"class":147},"})\n",[133,1940,1942],{"class":135,"line":1941},29,[133,1943,155],{"emptyLinePlaceholder":154},[133,1945,1947,1949,1951,1953],{"class":135,"line":1946},30,[133,1948,1547],{"class":167},[133,1950,1777],{"class":147},[133,1952,1780],{"class":139},[133,1954,1783],{"class":147},[133,1956,1958,1961],{"class":135,"line":1957},31,[133,1959,1960],{"class":167},"  stopPolling",[133,1962,1881],{"class":147},[133,1964,1966,1969],{"class":135,"line":1965},32,[133,1967,1968],{"class":167},"  removeBeforeListener",[133,1970,1971],{"class":147},"?.()\n",[133,1973,1975],{"class":135,"line":1974},33,[133,1976,1938],{"class":147},[133,1978,1980,1983,1985],{"class":135,"line":1979},34,[133,1981,1982],{"class":147},"\u003C/",[133,1984,1689],{"class":1688},[133,1986,1695],{"class":147},[11,1988,562,1989,1991,1992,1994,1995,1998],{},[37,1990,1668],{}," returns a function you call to remove the listener. The ",[37,1993,1547],{}," then cleans up the listener itself, for the case where the component ",[462,1996,1997],{},"does"," actually unmount (e.g., logout).",[11,2000,2001,2002,2004],{},"You still need the interval started in ",[37,2003,1543],{}," if you want it running on the initial page load. And you want the cleanup in the Inertia event so it runs on navigation whether or not Vue unmounts.",[112,2006,2008,2009,2012],{"id":2007},"option-3-usepoll-or-extracted-composable","Option 3: ",[37,2010,2011],{},"usePoll"," or extracted composable",[11,2014,2015],{},"If you find yourself doing this a lot, extract it into a composable:",[124,2017,2021],{"className":2018,"code":2019,"language":2020,"meta":129,"style":129},"language-javascript shiki shiki-themes github-dark","// composables/usePollingOnPage.js\nimport { onMounted, onUnmounted } from 'vue'\nimport { router } from '@inertiajs/vue3'\n\nexport function usePollingOnPage(callback, interval = 5000) {\n  let timer = null\n  let removeListener = null\n\n  const start = () => {\n    callback()\n    timer = setInterval(callback, interval)\n  }\n\n  const stop = () => {\n    if (timer) {\n      clearInterval(timer)\n      timer = null\n    }\n  }\n\n  onMounted(() => {\n    start()\n    removeListener = router.on('before', stop)\n  })\n\n  onUnmounted(() => {\n    stop()\n    removeListener?.()\n  })\n\n  return { start, stop }\n}\n","javascript",[37,2022,2023,2028,2038,2048,2052,2082,2094,2105,2109,2126,2133,2145,2149,2153,2168,2176,2184,2193,2197,2201,2205,2216,2223,2241,2245,2249,2260,2267,2274,2278,2282,2290],{"__ignoreMap":129},[133,2024,2025],{"class":135,"line":136},[133,2026,2027],{"class":188},"// composables/usePollingOnPage.js\n",[133,2029,2030,2032,2034,2036],{"class":135,"line":151},[133,2031,1700],{"class":139},[133,2033,1703],{"class":147},[133,2035,1706],{"class":139},[133,2037,1709],{"class":414},[133,2039,2040,2042,2044,2046],{"class":135,"line":158},[133,2041,1700],{"class":139},[133,2043,1716],{"class":147},[133,2045,1706],{"class":139},[133,2047,1721],{"class":414},[133,2049,2050],{"class":135,"line":179},[133,2051,155],{"emptyLinePlaceholder":154},[133,2053,2054,2057,2059,2062,2064,2068,2070,2073,2076,2079],{"class":135,"line":185},[133,2055,2056],{"class":139},"export",[133,2058,164],{"class":139},[133,2060,2061],{"class":167}," usePollingOnPage",[133,2063,204],{"class":147},[133,2065,2067],{"class":2066},"s9osk","callback",[133,2069,874],{"class":147},[133,2071,2072],{"class":2066},"interval",[133,2074,2075],{"class":139}," =",[133,2077,2078],{"class":143}," 5000",[133,2080,2081],{"class":147},") {\n",[133,2083,2084,2087,2090,2092],{"class":135,"line":192},[133,2085,2086],{"class":139},"  let",[133,2088,2089],{"class":147}," timer ",[133,2091,309],{"class":139},[133,2093,1738],{"class":143},[133,2095,2096,2098,2101,2103],{"class":135,"line":213},[133,2097,2086],{"class":139},[133,2099,2100],{"class":147}," removeListener ",[133,2102,309],{"class":139},[133,2104,1738],{"class":143},[133,2106,2107],{"class":135,"line":341},[133,2108,155],{"emptyLinePlaceholder":154},[133,2110,2111,2114,2117,2119,2122,2124],{"class":135,"line":364},[133,2112,2113],{"class":139},"  const",[133,2115,2116],{"class":167}," start",[133,2118,2075],{"class":139},[133,2120,2121],{"class":147}," () ",[133,2123,1780],{"class":139},[133,2125,1783],{"class":147},[133,2127,2128,2131],{"class":135,"line":384},[133,2129,2130],{"class":167},"    callback",[133,2132,1881],{"class":147},[133,2134,2135,2138,2140,2142],{"class":135,"line":396},[133,2136,2137],{"class":147},"    timer ",[133,2139,309],{"class":139},[133,2141,1774],{"class":167},[133,2143,2144],{"class":147},"(callback, interval)\n",[133,2146,2147],{"class":135,"line":401},[133,2148,1851],{"class":147},[133,2150,2151],{"class":135,"line":437},[133,2152,155],{"emptyLinePlaceholder":154},[133,2154,2155,2157,2160,2162,2164,2166],{"class":135,"line":1810},[133,2156,2113],{"class":139},[133,2158,2159],{"class":167}," stop",[133,2161,2075],{"class":139},[133,2163,2121],{"class":147},[133,2165,1780],{"class":139},[133,2167,1783],{"class":147},[133,2169,2170,2173],{"class":135,"line":1820},[133,2171,2172],{"class":139},"    if",[133,2174,2175],{"class":147}," (timer) {\n",[133,2177,2178,2181],{"class":135,"line":1829},[133,2179,2180],{"class":167},"      clearInterval",[133,2182,2183],{"class":147},"(timer)\n",[133,2185,2186,2189,2191],{"class":135,"line":1838},[133,2187,2188],{"class":147},"      timer ",[133,2190,309],{"class":139},[133,2192,1738],{"class":143},[133,2194,2195],{"class":135,"line":1848},[133,2196,1481],{"class":147},[133,2198,2199],{"class":135,"line":1854},[133,2200,1851],{"class":147},[133,2202,2203],{"class":135,"line":1859},[133,2204,155],{"emptyLinePlaceholder":154},[133,2206,2207,2210,2212,2214],{"class":135,"line":1864},[133,2208,2209],{"class":167},"  onMounted",[133,2211,1777],{"class":147},[133,2213,1780],{"class":139},[133,2215,1783],{"class":147},[133,2217,2218,2221],{"class":135,"line":1875},[133,2219,2220],{"class":167},"    start",[133,2222,1881],{"class":147},[133,2224,2225,2228,2230,2232,2234,2236,2238],{"class":135,"line":1884},[133,2226,2227],{"class":147},"    removeListener ",[133,2229,309],{"class":139},[133,2231,1903],{"class":147},[133,2233,1906],{"class":167},[133,2235,204],{"class":147},[133,2237,1911],{"class":414},[133,2239,2240],{"class":147},", stop)\n",[133,2242,2243],{"class":135,"line":1889},[133,2244,1932],{"class":147},[133,2246,2247],{"class":135,"line":1895},[133,2248,155],{"emptyLinePlaceholder":154},[133,2250,2251,2254,2256,2258],{"class":135,"line":1921},[133,2252,2253],{"class":167},"  onUnmounted",[133,2255,1777],{"class":147},[133,2257,1780],{"class":139},[133,2259,1783],{"class":147},[133,2261,2262,2265],{"class":135,"line":1929},[133,2263,2264],{"class":167},"    stop",[133,2266,1881],{"class":147},[133,2268,2269,2272],{"class":135,"line":1935},[133,2270,2271],{"class":167},"    removeListener",[133,2273,1971],{"class":147},[133,2275,2276],{"class":135,"line":1941},[133,2277,1932],{"class":147},[133,2279,2280],{"class":135,"line":1946},[133,2281,155],{"emptyLinePlaceholder":154},[133,2283,2284,2287],{"class":135,"line":1957},[133,2285,2286],{"class":139},"  return",[133,2288,2289],{"class":147}," { start, stop }\n",[133,2291,2292],{"class":135,"line":1965},[133,2293,216],{"class":147},[11,2295,2296],{},"Then in your page component:",[124,2298,2300],{"className":1676,"code":2299,"language":1678,"meta":129,"style":129},"\u003Cscript setup>\nimport { usePollingOnPage } from '@/composables/usePollingOnPage'\n\nusePollingOnPage(() => {\n  // fetch latest data\n}, 10000)\n\u003C/script>\n",[37,2301,2302,2312,2324,2328,2339,2344,2354],{"__ignoreMap":129},[133,2303,2304,2306,2308,2310],{"class":135,"line":136},[133,2305,1685],{"class":147},[133,2307,1689],{"class":1688},[133,2309,1692],{"class":167},[133,2311,1695],{"class":147},[133,2313,2314,2316,2319,2321],{"class":135,"line":151},[133,2315,1700],{"class":139},[133,2317,2318],{"class":147}," { usePollingOnPage } ",[133,2320,1706],{"class":139},[133,2322,2323],{"class":414}," '@/composables/usePollingOnPage'\n",[133,2325,2326],{"class":135,"line":158},[133,2327,155],{"emptyLinePlaceholder":154},[133,2329,2330,2333,2335,2337],{"class":135,"line":179},[133,2331,2332],{"class":167},"usePollingOnPage",[133,2334,1777],{"class":147},[133,2336,1780],{"class":139},[133,2338,1783],{"class":147},[133,2340,2341],{"class":135,"line":185},[133,2342,2343],{"class":188},"  // fetch latest data\n",[133,2345,2346,2349,2352],{"class":135,"line":192},[133,2347,2348],{"class":147},"}, ",[133,2350,2351],{"class":143},"10000",[133,2353,1799],{"class":147},[133,2355,2356,2358,2360],{"class":135,"line":213},[133,2357,1982],{"class":147},[133,2359,1689],{"class":1688},[133,2361,1695],{"class":147},[11,2363,2364,2365,2367],{},"Inertia also ships ",[37,2366,2011],{}," out of the box in recent versions, which handles most of this for you. Check your Inertia version — if you have it, use it.",[18,2369,2371],{"id":2370},"a-more-general-lesson","A more general lesson",[11,2373,2374,2375,2377,2378,2381],{},"The underlying point goes beyond polling. Any time you're doing something in ",[37,2376,1543],{}," that needs a matching cleanup — event listeners, subscriptions, WebSocket connections, keyboard shortcuts, ",[37,2379,2380],{},"requestAnimationFrame"," loops — you need to think about two different lifecycles in Inertia:",[26,2383,2384,2396],{},[29,2385,562,2386,2389,2390,2392,2393,2395],{},[32,2387,2388],{},"Vue component lifecycle"," (",[37,2391,1543],{}," / ",[37,2394,1547],{},")",[29,2397,562,2398,2389,2401,874,2404,2395],{},[32,2399,2400],{},"Inertia page lifecycle",[37,2402,2403],{},"router.on('before')",[37,2405,2406],{},"router.on('navigate')",[11,2408,2409],{},"In a pure Vue SPA, these are the same thing. In Inertia, they're not. Treat them as separate concerns and bind cleanup to the right one.",[18,2411,2413],{"id":2412},"debugging-tip","Debugging tip",[11,2415,2416,2417,2420,2421,2423,2424,2426],{},"If you're not sure whether a component is being reused across navigations, drop a ",[37,2418,2419],{},"console.log"," in ",[37,2422,1543],{}," and watch the console as you navigate. If you expect two mount events (leaving one page, entering another) but only see one, you've confirmed the instance is being reused — and any cleanup you have in ",[37,2425,1547],{}," is not running.",[11,2428,2429],{},"Vue DevTools can also show you the component tree before and after navigation. If the same instance ID is still there, it wasn't unmounted.",[18,2431,625],{"id":624},[11,2433,2434,2435,990],{},"Inertia's page preservation is a feature, not a bug. It makes the app feel fast and avoids unnecessary DOM thrashing. But it changes the assumption most Vue tutorials build on — that navigating \"away\" from a component always triggers ",[37,2436,1547],{},[11,2438,2439],{},"When you need cleanup that runs on every navigation, bind it to Inertia's router events, not Vue's lifecycle hooks. Or, even better, use a composable that handles both and forget about it.",[637,2441,2442],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":129,"searchDepth":158,"depth":158,"links":2444},[2445,2446,2447,2454,2455,2456],{"id":1557,"depth":151,"text":1558},{"id":1609,"depth":151,"text":1610},{"id":1636,"depth":151,"text":1637,"children":2448},[2449,2451,2452],{"id":1643,"depth":158,"text":2450},"Option 1: Use onBeforeUnmount — still doesn't help",{"id":1657,"depth":158,"text":1658},{"id":2007,"depth":158,"text":2453},"Option 3: usePoll or extracted composable",{"id":2370,"depth":151,"text":2371},{"id":2412,"depth":151,"text":2413},{"id":624,"depth":151,"text":625},"Inertia reuses component instances across page visits more aggressively than you'd expect. If your Vue cleanup code isn't running, this is probably the reason.",{},"https://digifellow.co.za/og/inertia-onunmounted.png","/blog/inertia-onunmounted","2025-12-02",{"title":1535,"description":2457},"blog/inertia-onunmounted",[1678,2465,666,652],"inertia","1MlUuDiebQna4DOzVCsXxo63fQRUhwDY4sD6VO_Zc_0",1776858404567]