[{"data":1,"prerenderedAt":1264},["ShallowReactive",2],{"\u002Farticles\u002Fembrace-the-framework-custom-collections-for-subscriptions":3,"article-series:embrace-the-framework":1213},{"id":4,"title":5,"body":6,"description":1196,"draft":1197,"extension":1198,"meta":1199,"navigation":203,"path":1200,"published":1201,"seo":1202,"seoDescription":1203,"series":1204,"seriesOrder":1205,"stem":1206,"tags":1207,"updated":1211,"__hash__":1212},"articles\u002Farticles\u002Fembrace-the-framework-custom-collections-for-subscriptions.md","Embrace the Framework: Put Subscription Business Rules in a Custom Collection",{"type":7,"value":8,"toc":1189},"minimark",[9,13,16,19,24,27,30,173,176,179,183,186,401,404,521,524,580,583,587,590,829,832,953,964,968,971,974,1142,1145,1149,1152,1155,1158,1179,1182,1185],[10,11,12],"p",{},"Collections are excellent for transforming data, but they become even more useful when they can speak the language of the application.",[10,14,15],{},"A subscription product, for example, has concepts that matter everywhere: active subscriptions, failed payments, customers at risk of churn, and monthly recurring revenue. Those are not just filters. They are business terms the whole team should be able to recognise.",[10,17,18],{},"A custom Eloquent Collection gives those terms a natural home.",[20,21,23],"h2",{"id":22},"the-repeated-rule-problem","The repeated-rule problem",[10,25,26],{},"Consider a SaaS application that needs to identify subscriptions requiring payment follow-up. A subscription should be followed up when it is active, its latest payment has failed, and the failure happened within the last seven days.",[10,28,29],{},"Without a shared abstraction, the rule often appears in a controller first:",[31,32,37],"pre",{"className":33,"code":34,"language":35,"meta":36,"style":36},"language-php shiki shiki-themes github-light github-dark","$subscriptionsToContact = $subscriptions\n    ->filter(fn (Subscription $subscription) =>\n        $subscription->status === 'active'\n        && $subscription->latestPayment?->status === 'failed'\n        && $subscription->latestPayment?->failed_at?->greaterThan(now()->subDays(7))\n    );\n","php","",[38,39,40,56,82,101,125,167],"code",{"__ignoreMap":36},[41,42,45,49,53],"span",{"class":43,"line":44},"line",1,[41,46,48],{"class":47},"sVt8B","$subscriptionsToContact ",[41,50,52],{"class":51},"szBVR","=",[41,54,55],{"class":47}," $subscriptions\n",[41,57,59,62,66,69,72,75,79],{"class":43,"line":58},2,[41,60,61],{"class":51},"    ->",[41,63,65],{"class":64},"sScJk","filter",[41,67,68],{"class":47},"(",[41,70,71],{"class":51},"fn",[41,73,74],{"class":47}," (",[41,76,78],{"class":77},"sj4cs","Subscription",[41,80,81],{"class":47}," $subscription) =>\n",[41,83,85,88,91,94,97],{"class":43,"line":84},3,[41,86,87],{"class":47},"        $subscription",[41,89,90],{"class":51},"->",[41,92,93],{"class":47},"status ",[41,95,96],{"class":51},"===",[41,98,100],{"class":99},"sZZnC"," 'active'\n",[41,102,104,107,110,112,115,118,120,122],{"class":43,"line":103},4,[41,105,106],{"class":51},"        &&",[41,108,109],{"class":47}," $subscription",[41,111,90],{"class":51},[41,113,114],{"class":47},"latestPayment",[41,116,117],{"class":51},"?->",[41,119,93],{"class":47},[41,121,96],{"class":51},[41,123,124],{"class":99}," 'failed'\n",[41,126,128,130,132,134,136,138,141,143,146,148,151,154,156,159,161,164],{"class":43,"line":127},5,[41,129,106],{"class":51},[41,131,109],{"class":47},[41,133,90],{"class":51},[41,135,114],{"class":47},[41,137,117],{"class":51},[41,139,140],{"class":47},"failed_at",[41,142,117],{"class":51},[41,144,145],{"class":64},"greaterThan",[41,147,68],{"class":47},[41,149,150],{"class":64},"now",[41,152,153],{"class":47},"()",[41,155,90],{"class":51},[41,157,158],{"class":64},"subDays",[41,160,68],{"class":47},[41,162,163],{"class":77},"7",[41,165,166],{"class":47},"))\n",[41,168,170],{"class":43,"line":169},6,[41,171,172],{"class":47},"    );\n",[10,174,175],{},"It is reasonable code. The problem is what happens next.",[10,177,178],{},"A dashboard needs the same list. Then a scheduled command needs it. Then the support team asks for a count. Each place can copy the chain, perhaps with a small variation. Before long, nobody is entirely certain whether every screen is using the same definition of \"requires payment follow-up\".",[20,180,182],{"id":181},"give-the-rule-a-name","Give the rule a name",[10,184,185],{},"First, create a Collection for the domain:",[31,187,189],{"className":33,"code":188,"language":35,"meta":36,"style":36},"\u003C?php\n\nnamespace App\\Collections;\n\nuse App\\Models\\Subscription;\nuse Illuminate\\Database\\Eloquent\\Collection;\n\nclass SubscriptionCollection extends Collection\n{\n    public function requiringPaymentFollowUp(): self\n    {\n        return $this->filter(fn (Subscription $subscription) =>\n            $subscription->status === 'active'\n            && $subscription->latestPayment?->status === 'failed'\n            && $subscription->latestPayment?->failed_at?->greaterThan(now()->subDays(7))\n        );\n    }\n}\n",[38,190,191,199,205,216,220,230,239,244,259,265,285,291,314,328,348,383,389,395],{"__ignoreMap":36},[41,192,193,196],{"class":43,"line":44},[41,194,195],{"class":51},"\u003C?",[41,197,198],{"class":77},"php\n",[41,200,201],{"class":43,"line":58},[41,202,204],{"emptyLinePlaceholder":203},true,"\n",[41,206,207,210,213],{"class":43,"line":84},[41,208,209],{"class":51},"namespace",[41,211,212],{"class":64}," App\\Collections",[41,214,215],{"class":47},";\n",[41,217,218],{"class":43,"line":103},[41,219,204],{"emptyLinePlaceholder":203},[41,221,222,225,228],{"class":43,"line":127},[41,223,224],{"class":51},"use",[41,226,227],{"class":77}," App\\Models\\Subscription",[41,229,215],{"class":47},[41,231,232,234,237],{"class":43,"line":169},[41,233,224],{"class":51},[41,235,236],{"class":77}," Illuminate\\Database\\Eloquent\\Collection",[41,238,215],{"class":47},[41,240,242],{"class":43,"line":241},7,[41,243,204],{"emptyLinePlaceholder":203},[41,245,247,250,253,256],{"class":43,"line":246},8,[41,248,249],{"class":51},"class",[41,251,252],{"class":64}," SubscriptionCollection",[41,254,255],{"class":51}," extends",[41,257,258],{"class":64}," Collection\n",[41,260,262],{"class":43,"line":261},9,[41,263,264],{"class":47},"{\n",[41,266,268,271,274,277,279,282],{"class":43,"line":267},10,[41,269,270],{"class":51},"    public",[41,272,273],{"class":51}," function",[41,275,276],{"class":64}," requiringPaymentFollowUp",[41,278,153],{"class":47},[41,280,281],{"class":51},":",[41,283,284],{"class":51}," self\n",[41,286,288],{"class":43,"line":287},11,[41,289,290],{"class":47},"    {\n",[41,292,294,297,300,302,304,306,308,310,312],{"class":43,"line":293},12,[41,295,296],{"class":51},"        return",[41,298,299],{"class":77}," $this",[41,301,90],{"class":51},[41,303,65],{"class":64},[41,305,68],{"class":47},[41,307,71],{"class":51},[41,309,74],{"class":47},[41,311,78],{"class":77},[41,313,81],{"class":47},[41,315,317,320,322,324,326],{"class":43,"line":316},13,[41,318,319],{"class":47},"            $subscription",[41,321,90],{"class":51},[41,323,93],{"class":47},[41,325,96],{"class":51},[41,327,100],{"class":99},[41,329,331,334,336,338,340,342,344,346],{"class":43,"line":330},14,[41,332,333],{"class":51},"            &&",[41,335,109],{"class":47},[41,337,90],{"class":51},[41,339,114],{"class":47},[41,341,117],{"class":51},[41,343,93],{"class":47},[41,345,96],{"class":51},[41,347,124],{"class":99},[41,349,351,353,355,357,359,361,363,365,367,369,371,373,375,377,379,381],{"class":43,"line":350},15,[41,352,333],{"class":51},[41,354,109],{"class":47},[41,356,90],{"class":51},[41,358,114],{"class":47},[41,360,117],{"class":51},[41,362,140],{"class":47},[41,364,117],{"class":51},[41,366,145],{"class":64},[41,368,68],{"class":47},[41,370,150],{"class":64},[41,372,153],{"class":47},[41,374,90],{"class":51},[41,376,158],{"class":64},[41,378,68],{"class":47},[41,380,163],{"class":77},[41,382,166],{"class":47},[41,384,386],{"class":43,"line":385},16,[41,387,388],{"class":47},"        );\n",[41,390,392],{"class":43,"line":391},17,[41,393,394],{"class":47},"    }\n",[41,396,398],{"class":43,"line":397},18,[41,399,400],{"class":47},"}\n",[10,402,403],{},"Then tell the model to use it:",[31,405,407],{"className":33,"code":406,"language":35,"meta":36,"style":36},"\u003C?php\n\nnamespace App\\Models;\n\nuse App\\Collections\\SubscriptionCollection;\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass Subscription extends Model\n{\n    public function newCollection(array $models = []): SubscriptionCollection\n    {\n        return new SubscriptionCollection($models);\n    }\n}\n",[38,408,409,415,419,428,432,441,450,454,466,470,497,501,513,517],{"__ignoreMap":36},[41,410,411,413],{"class":43,"line":44},[41,412,195],{"class":51},[41,414,198],{"class":77},[41,416,417],{"class":43,"line":58},[41,418,204],{"emptyLinePlaceholder":203},[41,420,421,423,426],{"class":43,"line":84},[41,422,209],{"class":51},[41,424,425],{"class":64}," App\\Models",[41,427,215],{"class":47},[41,429,430],{"class":43,"line":103},[41,431,204],{"emptyLinePlaceholder":203},[41,433,434,436,439],{"class":43,"line":127},[41,435,224],{"class":51},[41,437,438],{"class":77}," App\\Collections\\SubscriptionCollection",[41,440,215],{"class":47},[41,442,443,445,448],{"class":43,"line":169},[41,444,224],{"class":51},[41,446,447],{"class":77}," Illuminate\\Database\\Eloquent\\Model",[41,449,215],{"class":47},[41,451,452],{"class":43,"line":241},[41,453,204],{"emptyLinePlaceholder":203},[41,455,456,458,461,463],{"class":43,"line":246},[41,457,249],{"class":51},[41,459,460],{"class":64}," Subscription",[41,462,255],{"class":51},[41,464,465],{"class":64}," Model\n",[41,467,468],{"class":43,"line":261},[41,469,264],{"class":47},[41,471,472,474,476,479,481,484,487,489,492,494],{"class":43,"line":267},[41,473,270],{"class":51},[41,475,273],{"class":51},[41,477,478],{"class":64}," newCollection",[41,480,68],{"class":47},[41,482,483],{"class":51},"array",[41,485,486],{"class":47}," $models ",[41,488,52],{"class":51},[41,490,491],{"class":47}," [])",[41,493,281],{"class":51},[41,495,496],{"class":77}," SubscriptionCollection\n",[41,498,499],{"class":43,"line":287},[41,500,290],{"class":47},[41,502,503,505,508,510],{"class":43,"line":293},[41,504,296],{"class":51},[41,506,507],{"class":51}," new",[41,509,252],{"class":77},[41,511,512],{"class":47},"($models);\n",[41,514,515],{"class":43,"line":316},[41,516,394],{"class":47},[41,518,519],{"class":43,"line":330},[41,520,400],{"class":47},[10,522,523],{},"Now any Eloquent result containing subscriptions can use the new language:",[31,525,527],{"className":33,"code":526,"language":35,"meta":36,"style":36},"$subscriptionsToContact = Subscription::query()\n    ->with('latestPayment')\n    ->get()\n    ->requiringPaymentFollowUp();\n",[38,528,529,546,561,570],{"__ignoreMap":36},[41,530,531,533,535,537,540,543],{"class":43,"line":44},[41,532,48],{"class":47},[41,534,52],{"class":51},[41,536,460],{"class":77},[41,538,539],{"class":51},"::",[41,541,542],{"class":64},"query",[41,544,545],{"class":47},"()\n",[41,547,548,550,553,555,558],{"class":43,"line":58},[41,549,61],{"class":51},[41,551,552],{"class":64},"with",[41,554,68],{"class":47},[41,556,557],{"class":99},"'latestPayment'",[41,559,560],{"class":47},")\n",[41,562,563,565,568],{"class":43,"line":84},[41,564,61],{"class":51},[41,566,567],{"class":64},"get",[41,569,545],{"class":47},[41,571,572,574,577],{"class":43,"line":103},[41,573,61],{"class":51},[41,575,576],{"class":64},"requiringPaymentFollowUp",[41,578,579],{"class":47},"();\n",[10,581,582],{},"The controller does not need to know how the rule is calculated. It asks a question the product team would understand.",[20,584,586],{"id":585},"keep-related-behaviour-together","Keep related behaviour together",[10,588,589],{},"Once the Collection exists, it can hold other operations that only make sense for a group of subscriptions.",[31,591,593],{"className":33,"code":592,"language":35,"meta":36,"style":36},"class SubscriptionCollection extends Collection\n{\n    public function active(): self\n    {\n        return $this->where('status', 'active');\n    }\n\n    public function requiringPaymentFollowUp(): self\n    {\n        return $this->active()\n            ->filter(fn (Subscription $subscription) =>\n                $subscription->latestPayment?->status === 'failed'\n                && $subscription->latestPayment?->failed_at?->greaterThan(now()->subDays(7))\n            );\n    }\n\n    public function monthlyRecurringRevenue(): int\n    {\n        return $this->active()->sum('monthly_amount_in_cents');\n    }\n}\n",[38,594,595,605,609,624,628,653,657,661,675,679,692,709,726,761,766,770,774,790,794,819,824],{"__ignoreMap":36},[41,596,597,599,601,603],{"class":43,"line":44},[41,598,249],{"class":51},[41,600,252],{"class":64},[41,602,255],{"class":51},[41,604,258],{"class":64},[41,606,607],{"class":43,"line":58},[41,608,264],{"class":47},[41,610,611,613,615,618,620,622],{"class":43,"line":84},[41,612,270],{"class":51},[41,614,273],{"class":51},[41,616,617],{"class":64}," active",[41,619,153],{"class":47},[41,621,281],{"class":51},[41,623,284],{"class":51},[41,625,626],{"class":43,"line":103},[41,627,290],{"class":47},[41,629,630,632,634,636,639,641,644,647,650],{"class":43,"line":127},[41,631,296],{"class":51},[41,633,299],{"class":77},[41,635,90],{"class":51},[41,637,638],{"class":64},"where",[41,640,68],{"class":47},[41,642,643],{"class":99},"'status'",[41,645,646],{"class":47},", ",[41,648,649],{"class":99},"'active'",[41,651,652],{"class":47},");\n",[41,654,655],{"class":43,"line":169},[41,656,394],{"class":47},[41,658,659],{"class":43,"line":241},[41,660,204],{"emptyLinePlaceholder":203},[41,662,663,665,667,669,671,673],{"class":43,"line":246},[41,664,270],{"class":51},[41,666,273],{"class":51},[41,668,276],{"class":64},[41,670,153],{"class":47},[41,672,281],{"class":51},[41,674,284],{"class":51},[41,676,677],{"class":43,"line":261},[41,678,290],{"class":47},[41,680,681,683,685,687,690],{"class":43,"line":267},[41,682,296],{"class":51},[41,684,299],{"class":77},[41,686,90],{"class":51},[41,688,689],{"class":64},"active",[41,691,545],{"class":47},[41,693,694,697,699,701,703,705,707],{"class":43,"line":287},[41,695,696],{"class":51},"            ->",[41,698,65],{"class":64},[41,700,68],{"class":47},[41,702,71],{"class":51},[41,704,74],{"class":47},[41,706,78],{"class":77},[41,708,81],{"class":47},[41,710,711,714,716,718,720,722,724],{"class":43,"line":293},[41,712,713],{"class":47},"                $subscription",[41,715,90],{"class":51},[41,717,114],{"class":47},[41,719,117],{"class":51},[41,721,93],{"class":47},[41,723,96],{"class":51},[41,725,124],{"class":99},[41,727,728,731,733,735,737,739,741,743,745,747,749,751,753,755,757,759],{"class":43,"line":316},[41,729,730],{"class":51},"                &&",[41,732,109],{"class":47},[41,734,90],{"class":51},[41,736,114],{"class":47},[41,738,117],{"class":51},[41,740,140],{"class":47},[41,742,117],{"class":51},[41,744,145],{"class":64},[41,746,68],{"class":47},[41,748,150],{"class":64},[41,750,153],{"class":47},[41,752,90],{"class":51},[41,754,158],{"class":64},[41,756,68],{"class":47},[41,758,163],{"class":77},[41,760,166],{"class":47},[41,762,763],{"class":43,"line":330},[41,764,765],{"class":47},"            );\n",[41,767,768],{"class":43,"line":350},[41,769,394],{"class":47},[41,771,772],{"class":43,"line":385},[41,773,204],{"emptyLinePlaceholder":203},[41,775,776,778,780,783,785,787],{"class":43,"line":391},[41,777,270],{"class":51},[41,779,273],{"class":51},[41,781,782],{"class":64}," monthlyRecurringRevenue",[41,784,153],{"class":47},[41,786,281],{"class":51},[41,788,789],{"class":51}," int\n",[41,791,792],{"class":43,"line":397},[41,793,290],{"class":47},[41,795,797,799,801,803,805,807,809,812,814,817],{"class":43,"line":796},19,[41,798,296],{"class":51},[41,800,299],{"class":77},[41,802,90],{"class":51},[41,804,689],{"class":64},[41,806,153],{"class":47},[41,808,90],{"class":51},[41,810,811],{"class":64},"sum",[41,813,68],{"class":47},[41,815,816],{"class":99},"'monthly_amount_in_cents'",[41,818,652],{"class":47},[41,820,822],{"class":43,"line":821},20,[41,823,394],{"class":47},[41,825,827],{"class":43,"line":826},21,[41,828,400],{"class":47},[10,830,831],{},"This gives us expressive application code:",[31,833,835],{"className":33,"code":834,"language":35,"meta":36,"style":36},"$subscriptions = Subscription::query()\n    ->with('latestPayment')\n    ->get();\n\n$summary = [\n    'active_subscriptions' => $subscriptions->active()->count(),\n    'payment_follow_ups' => $subscriptions->requiringPaymentFollowUp()->count(),\n    'monthly_recurring_revenue' => $subscriptions->monthlyRecurringRevenue(),\n];\n",[38,836,837,852,864,872,876,886,911,932,948],{"__ignoreMap":36},[41,838,839,842,844,846,848,850],{"class":43,"line":44},[41,840,841],{"class":47},"$subscriptions ",[41,843,52],{"class":51},[41,845,460],{"class":77},[41,847,539],{"class":51},[41,849,542],{"class":64},[41,851,545],{"class":47},[41,853,854,856,858,860,862],{"class":43,"line":58},[41,855,61],{"class":51},[41,857,552],{"class":64},[41,859,68],{"class":47},[41,861,557],{"class":99},[41,863,560],{"class":47},[41,865,866,868,870],{"class":43,"line":84},[41,867,61],{"class":51},[41,869,567],{"class":64},[41,871,579],{"class":47},[41,873,874],{"class":43,"line":103},[41,875,204],{"emptyLinePlaceholder":203},[41,877,878,881,883],{"class":43,"line":127},[41,879,880],{"class":47},"$summary ",[41,882,52],{"class":51},[41,884,885],{"class":47}," [\n",[41,887,888,891,894,897,899,901,903,905,908],{"class":43,"line":169},[41,889,890],{"class":99},"    'active_subscriptions'",[41,892,893],{"class":51}," =>",[41,895,896],{"class":47}," $subscriptions",[41,898,90],{"class":51},[41,900,689],{"class":64},[41,902,153],{"class":47},[41,904,90],{"class":51},[41,906,907],{"class":64},"count",[41,909,910],{"class":47},"(),\n",[41,912,913,916,918,920,922,924,926,928,930],{"class":43,"line":241},[41,914,915],{"class":99},"    'payment_follow_ups'",[41,917,893],{"class":51},[41,919,896],{"class":47},[41,921,90],{"class":51},[41,923,576],{"class":64},[41,925,153],{"class":47},[41,927,90],{"class":51},[41,929,907],{"class":64},[41,931,910],{"class":47},[41,933,934,937,939,941,943,946],{"class":43,"line":246},[41,935,936],{"class":99},"    'monthly_recurring_revenue'",[41,938,893],{"class":51},[41,940,896],{"class":47},[41,942,90],{"class":51},[41,944,945],{"class":64},"monthlyRecurringRevenue",[41,947,910],{"class":47},[41,949,950],{"class":43,"line":261},[41,951,952],{"class":47},"];\n",[10,954,955,956,959,960,963],{},"The important detail is that these methods are not generic helpers. They represent concepts specific to subscriptions. A ",[38,957,958],{},"SubscriptionCollection"," is a better home for them than a broad ",[38,961,962],{},"DataManager"," or a controller utility trait.",[20,965,967],{"id":966},"this-also-makes-change-safer","This also makes change safer",[10,969,970],{},"Business rules change. A follow-up may need to exclude subscriptions with an open support ticket, or revenue may need to exclude subscriptions in a trial period.",[10,972,973],{},"With copied filters, that change requires finding every version of the rule and hoping none are missed. With a custom Collection, we have one named place to update and a focused unit test to describe the expected behaviour.",[31,975,977],{"className":33,"code":976,"language":35,"meta":36,"style":36},"it('finds active subscriptions with a recent failed payment', function () {\n    $subscriptions = new SubscriptionCollection([\n        Subscription::factory()->active()->withFailedPayment()->create(),\n        Subscription::factory()->cancelled()->withFailedPayment()->create(),\n        Subscription::factory()->active()->withSuccessfulPayment()->create(),\n    ]);\n\n    expect($subscriptions->requiringPaymentFollowUp())->toHaveCount(1);\n});\n",[38,978,979,997,1011,1043,1072,1101,1106,1110,1137],{"__ignoreMap":36},[41,980,981,984,986,989,991,994],{"class":43,"line":44},[41,982,983],{"class":64},"it",[41,985,68],{"class":47},[41,987,988],{"class":99},"'finds active subscriptions with a recent failed payment'",[41,990,646],{"class":47},[41,992,993],{"class":51},"function",[41,995,996],{"class":47}," () {\n",[41,998,999,1002,1004,1006,1008],{"class":43,"line":58},[41,1000,1001],{"class":47},"    $subscriptions ",[41,1003,52],{"class":51},[41,1005,507],{"class":51},[41,1007,252],{"class":77},[41,1009,1010],{"class":47},"([\n",[41,1012,1013,1016,1018,1021,1023,1025,1027,1029,1031,1034,1036,1038,1041],{"class":43,"line":84},[41,1014,1015],{"class":77},"        Subscription",[41,1017,539],{"class":51},[41,1019,1020],{"class":64},"factory",[41,1022,153],{"class":47},[41,1024,90],{"class":51},[41,1026,689],{"class":64},[41,1028,153],{"class":47},[41,1030,90],{"class":51},[41,1032,1033],{"class":64},"withFailedPayment",[41,1035,153],{"class":47},[41,1037,90],{"class":51},[41,1039,1040],{"class":64},"create",[41,1042,910],{"class":47},[41,1044,1045,1047,1049,1051,1053,1055,1058,1060,1062,1064,1066,1068,1070],{"class":43,"line":103},[41,1046,1015],{"class":77},[41,1048,539],{"class":51},[41,1050,1020],{"class":64},[41,1052,153],{"class":47},[41,1054,90],{"class":51},[41,1056,1057],{"class":64},"cancelled",[41,1059,153],{"class":47},[41,1061,90],{"class":51},[41,1063,1033],{"class":64},[41,1065,153],{"class":47},[41,1067,90],{"class":51},[41,1069,1040],{"class":64},[41,1071,910],{"class":47},[41,1073,1074,1076,1078,1080,1082,1084,1086,1088,1090,1093,1095,1097,1099],{"class":43,"line":127},[41,1075,1015],{"class":77},[41,1077,539],{"class":51},[41,1079,1020],{"class":64},[41,1081,153],{"class":47},[41,1083,90],{"class":51},[41,1085,689],{"class":64},[41,1087,153],{"class":47},[41,1089,90],{"class":51},[41,1091,1092],{"class":64},"withSuccessfulPayment",[41,1094,153],{"class":47},[41,1096,90],{"class":51},[41,1098,1040],{"class":64},[41,1100,910],{"class":47},[41,1102,1103],{"class":43,"line":169},[41,1104,1105],{"class":47},"    ]);\n",[41,1107,1108],{"class":43,"line":241},[41,1109,204],{"emptyLinePlaceholder":203},[41,1111,1112,1115,1118,1120,1122,1125,1127,1130,1132,1135],{"class":43,"line":246},[41,1113,1114],{"class":64},"    expect",[41,1116,1117],{"class":47},"($subscriptions",[41,1119,90],{"class":51},[41,1121,576],{"class":64},[41,1123,1124],{"class":47},"())",[41,1126,90],{"class":51},[41,1128,1129],{"class":64},"toHaveCount",[41,1131,68],{"class":47},[41,1133,1134],{"class":77},"1",[41,1136,652],{"class":47},[41,1138,1139],{"class":43,"line":261},[41,1140,1141],{"class":47},"});\n",[10,1143,1144],{},"The test is easier to write because the method is focused. It also documents the business rule in a way a long controller test often does not.",[20,1146,1148],{"id":1147},"a-custom-collection-is-not-a-dumping-ground","A custom Collection is not a dumping ground",[10,1150,1151],{},"It is tempting to put every operation on a custom Collection once one exists. I would avoid that.",[10,1153,1154],{},"A collection method should answer a question about the group of models it contains. If it needs to load more data, persist changes, send notifications, or coordinate several unrelated services, it probably belongs somewhere else.",[10,1156,1157],{},"The simple rule I use is this: if the method sounds natural after the collection name, it is probably a good candidate.",[1159,1160,1161,1168,1173],"ul",{},[1162,1163,1164,1167],"li",{},[38,1165,1166],{},"subscriptions->active()"," makes sense.",[1162,1169,1170,1167],{},[38,1171,1172],{},"subscriptions->monthlyRecurringRevenue()",[1162,1174,1175,1178],{},[38,1176,1177],{},"subscriptions->sendRenewalEmails()"," is probably coordinating a separate process.",[10,1180,1181],{},"Custom Collections let us extend Laravel without moving away from Laravel. The framework gives us the extension point; our application adds the business language.",[10,1183,1184],{},"Next, we will look at a different kind of complexity: one task that needs to move through several distinct processing stages. That is where Laravel Pipelines are a great fit.",[1186,1187,1188],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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);}",{"title":36,"searchDepth":58,"depth":58,"links":1190},[1191,1192,1193,1194,1195],{"id":22,"depth":58,"text":23},{"id":181,"depth":58,"text":182},{"id":585,"depth":58,"text":586},{"id":966,"depth":58,"text":967},{"id":1147,"depth":58,"text":1148},"Custom Eloquent Collections give repeated business rules a clear home and let application code speak the language of the domain.",false,"md",{},"\u002Farticles\u002Fembrace-the-framework-custom-collections-for-subscriptions","2026-07-28",{"title":5,"description":1196},"Learn how custom Laravel Collections keep subscription business rules readable, reusable, and out of controllers.","embrace-the-framework","3","articles\u002Fembrace-the-framework-custom-collections-for-subscriptions",[1208,35,1209,1210],"laravel","collections","business-logic",null,"rFFWYDJ9hmXpvt9Txs-cPC2GON45UzPrBNXrx77PGIY",[1214,1215,1220,1225,1230,1234,1239,1244,1248,1252,1256,1260],{"path":1200,"title":5,"published":1201,"series":1204,"seriesOrder":1205},{"path":1216,"title":1217,"published":1218,"series":1204,"seriesOrder":1219},"\u002Farticles\u002Fembrace-the-framework-customer-eligibility-with-custom-collections","Embrace the Framework: Make Eligibility Rules Read Like Business Policy","2026-08-04","6",{"path":1221,"title":1222,"published":1223,"series":1204,"seriesOrder":1224},"\u002Farticles\u002Fembrace-the-framework-operational-reports-with-collections","Embrace the Framework: Build Clear Operational Reports With Collections","2026-08-02","5",{"path":1226,"title":1227,"published":1228,"series":1204,"seriesOrder":1229},"\u002Farticles\u002Fembrace-the-framework-order-fulfilment-with-collections","Embrace the Framework: Make Order Fulfilment Clearer With Collections","2026-07-25","2",{"path":1231,"title":1232,"published":1233,"series":1204,"seriesOrder":163},"\u002Farticles\u002Fembrace-the-framework-order-processing-pipeline-that-can-grow","Embrace the Framework: Build an Order Processing Pipeline That Can Grow","2026-08-06",{"path":1235,"title":1236,"published":1237,"series":1204,"seriesOrder":1238},"\u002Farticles\u002Fembrace-the-framework-order-processing-with-pipelines","Embrace the Framework: Break Order Processing Into Steps With Pipelines","2026-07-31","4",{"path":1240,"title":1241,"published":1242,"series":1204,"seriesOrder":1243},"\u002Farticles\u002Fembrace-the-framework-supplier-imports-with-collections-and-pipelines","Embrace the Framework: Combine Collections and Pipelines for Reliable Supplier Imports","2026-08-08","8",{"path":1245,"title":1246,"published":1247,"series":1204,"seriesOrder":1134},"\u002Farticles\u002Fembrace-the-framework-why-working-with-laravel-beats-working-around-it","Embrace the Framework: Why Working With Laravel Beats Working Around It","2026-07-23",{"path":1249,"title":1250,"published":1251,"series":1211,"seriesOrder":1211},"\u002Farticles\u002Fsimple-search-helper","A Simple Laravel Search Helper","2023-08-06",{"path":1253,"title":1254,"published":1255,"series":1211,"seriesOrder":1211},"\u002Farticles\u002Fsvg-tidy-local-svg-optimiser","SVG Tidy: Clean and Optimise SVGs Locally","2026-07-21",{"path":1257,"title":1258,"published":1259,"series":1211,"seriesOrder":1211},"\u002Farticles\u002Ftools-i-use","Tools I Use","2024-05-26",{"path":1261,"title":1262,"published":1263,"series":1211,"seriesOrder":1211},"\u002Farticles\u002Fwelcome","Welcome","2023-08-05",1785659753486]