Advent of code 2021/4
Ajax Direct

Answer 309ms

Part 1 : 64084 Part 2 : 12833
class Bingo extends Grid
{
    public function isCompleted()
    {
        return // Compare the number of rows/columns that are not empty to the width/height of the grid
        (clone $this)->rows()->callEach()->filter()->filter()->count() != $this->height()
        ||
        (clone $this)->columns()->callEach()->filter()->filter()->count() != $this->width();
    }
}


// Format the input so it's easier to work with
$input = $input->replace(["\n ", "  "], ["\n", " "]);

// Extract numbers and bingo grids
$numbers = ($input = $input->split("\n\n"))->splice(0, 1);
$bingos  = $input->map(fn ($grid) => (new Bingo($grid, " ")));
$completed_boards = set();

// Mark boards number by number and keep track of all the completed boards
foreach ($numbers[0]->split(",") as $number) {
    foreach ($bingos as $bingo) {
        if (empty($bingo->completed) && $pos = $bingo->search($number)) {
            $bingo->set($pos, false);
            $bingo->clearCache();
            if ($bingo->completed = $bingo->isCompleted()) {
                $completed_boards[] = [$bingo, $number];
                if ($completed_boards->count() >= $bingos->count()) break 2;
            }
        }
    }
}

// ==================================================
// > SOLUTIONS
// ==================================================
[$winner_board, $winner_number] = $completed_boards->first();
$solution_1 = $winner_board->cells()->sum() * $winner_number->int;

[$loser_board, $loser_number] = $completed_boards->last();
$solution_2 = $loser_board->cells()->sum() * $loser_number->int;