info('Auto-close is disabled (ticketing.auto_close_days = 0).'); return 0; } $cutoff = now()->subDays($days); /* * CLOSED ONE AT A TIME, THROUGH THE MODEL — [central-ticketing] #4781. * * This was a single `->update(['status' => 'closed'])`, which is one * statement and no model events. Since #4781 a ticket's status change is * an audited fact, and a bulk update would have left exactly one way of * closing a ticket invisible: the Changes log would show a ticket going * open → resolved and then nothing, while the row itself said `closed`. * "Who closed this" is the question the audit trail exists to answer, * and "the nightly sweep did, on this date" is a real answer — but only * if the sweep writes it. * * Per-ticket rather than one summary event because the answer has to be * on THE TICKET: the per-record history panel reads rows whose subject * is that ticket, and an aggregate "closed 14 tickets" row would not * appear in any of their histories. * * `chunkById`, so a first sweep over a long backlog does not load every * resolved ticket into memory at once. Paging by id is what makes this * safe while the filtered column is being written: each page continues * from the last id seen rather than from an offset, so rows dropping out * of `status = 'resolved'` cannot cause a page to be skipped. */ $count = 0; Ticket::where('status', 'resolved') ->where('updated_at', '<=', $cutoff) ->chunkById(200, function ($tickets) use (&$count) { foreach ($tickets as $ticket) { $ticket->forceFill(['status' => 'closed'])->save(); $count++; } }); $this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago."); return 0; } }