Atelier : recodez la Collection de Laravel
Séance 5 · ~2 périodes
À la fin de ce chapitre, vous serez capables de :
- écrire une pile, une liste et une collection qui font passer les tests fournis.
- lire et écrire une fonction passée en paramètre, avec la syntaxe courte
fncomme avecfunction. - justifier pourquoi une transformation renvoie une nouvelle collection au lieu de modifier l'ancienne.
- estimer le coût d'un bout de code en O(1), O(n) ou O(n²).
- lire une chaîne d'appels
collect(...)->filter(...)->map(...)->sum()dans la documentation de Laravel.
Le dépôt : https://github.com/opmvpc/collection-26. Forkez-le maintenant. Son code vit dans le namespace Collections.
1. Le code qui sent mauvais
Cinq minutes, en binôme. $items est le contenu du sac d'Arthur. Le but est d'afficher les objets légers et leur poids total. Ce code sent mauvais : plusieurs détails d'écriture y trahissent des défauts de conception. Lesquels ?
$items = [
['name' => 'Épée longue', 'weight' => 3.5],
['name' => 'Potion de soin', 'weight' => 0.5],
['name' => 'Bouclier', 'weight' => 6.0],
['name' => 'Torche', 'weight' => 1.0],
];
$total = 0;
$noms = '';
for ($i = 0; $i < count($items); $i++) {
if ($items[$i]['weight'] < 5) {
$total = $total + $items[$i]['weight'];
$noms = $noms . $items[$i]['name'] . ', ';
}
}
echo 'Léger : ' . $noms . '(' . $total . ' kg)', PHP_EOL;
Léger : Épée longue, Potion de soin, Torche, (5 kg)
2. Une Collection dans Laravel
Dans Laravel, User::all() ne renvoie pas un tableau PHP. Il renvoie un objet : une Illuminate\Database\Eloquent\Collection, qui hérite de Illuminate\Support\Collection. C'est la même chose pour $post->comments, pour le résultat d'un where()->get(), et pour presque tout ce qui contient plusieurs éléments. La documentation de Laravel suppose que vous savez lire ceci :
$noms = $users->filter(fn ($user) => $user->isActive())
->sortBy(fn ($user) => $user->name)
->pluck('name');
Pour lire cet exemple, il faut comprendre filter(), sortBy(), pluck() et la syntaxe fn. Tant que ces quatre choses restent floues, une grande partie de la documentation reste illisible. L'atelier construit cette Collection en trois étapes, puis compare son comportement à celui de Laravel.
3. Le dépôt et les commandes
git clone https://github.com/<votre-compte>/collection-26.git
cd collection-26
composer install
composer test
Tout est rouge au départ. Chaque corps de méthode à compléter dans src/ lève LogicException('À implémenter'). Les constructeurs, guardIndex() et la classe Node sont déjà écrits. Complétez le reste pour qu'il respecte les tests fournis.
Une étape à la fois :
composer test -- --group=etape-1
composer test -- --group=etape-2
composer test -- --group=etape-3
composer test -- --group=bonus
L'onglet Actions de votre fork lance une vérification par étape, indépendante des autres. Une étape 1 rouge n'empêche pas l'étape 3 de tourner.
Deux règles, comme au chapitre 8 : ne modifiez pas les tests fournis, et ne touchez pas à ListInterface. Les tests sont le cahier des charges, l'interface est le contrat.
4. Étape 1 : la pile
Fourni : src/Stack.php, avec les cinq signatures et les indices en commentaire. À écrire : les cinq corps de méthode.
Une pile d'assiettes : vous posez sur le dessus, vous reprenez sur le dessus. Le dernier arrivé est le premier servi. C'est une pile LIFO, pour last in, first out. Dans le Donjon, c'est l'historique des salles visitées, et faire demi-tour revient à reprendre la dernière.
@startuml
interface Countable {
+count(): int
}
class Stack {
-elements: array
+push(element: mixed): void
+pop(): mixed
+peek(): mixed
+isEmpty(): bool
+count(): int
}
Countable <|.. Stack
note right of Stack : Le dernier empilé est le premier dépilé.
@enduml
mixed est le type PHP qui accepte n'importe quelle valeur : une pile ne regarde pas ce qu'elle range. push() pose sur le dessus, pop() retire le dessus et le renvoie, peek() le consulte sans le retirer.

Une fois la classe écrite :
$visitees = new Stack();
$visitees->push('Entrée');
$visitees->push('Couloir');
$visitees->push('Salle du trône');
echo $visitees->peek(), PHP_EOL;
echo $visitees->pop(), PHP_EOL;
echo $visitees->pop(), PHP_EOL;
echo count($visitees), PHP_EOL;
Salle du trône
Salle du trône
Couloir
1
Un mot sur count($visitees). Votre classe déclare implements Countable : PHP accepte donc de lui appliquer la fonction native count(), qui appelle votre méthode count(). Comme au chapitre 7, une interface native ouvre la syntaxe du langage à vos objets.
Ce que les tests attendent
| Méthode | Comportement vérifié |
|---|---|
push(), count() |
Une pile neuve est vide. Après trois push(), count() vaut 3. |
pop() |
Rend les éléments dans l'ordre inverse de l'empilement, puis la pile redevient vide. |
peek() |
Rend le sommet sans le retirer : deux appels de suite donnent la même valeur. |
| pile vide | pop() et peek() lèvent UnderflowException. |
Countable |
count($stack) fonctionne. |
| contenu | La pile accepte un entier comme un tableau. |
UnderflowException est une exception de PHP, prête à l'emploi, comme InvalidArgumentException au chapitre 3 : elle dit qu'on retire d'un conteneur vide. Les fichiers fournis l'importent déjà en haut.
Indice : tout se joue à la fin du tableau. array_pop() retire le dernier élément et vous le rend. array_key_last() donne l'index du dernier sans y toucher.
Vérification : composer test -- --group=etape-1, 9 tests verts.
Question : pourquoi une pile n'a-t-elle pas de méthode get(int $index) ? Qu'est-ce que vous gagnez à interdire quelque chose ?
5. Étape 2 : la liste et son contrat
Fourni : l'interface src/ListInterface.php, à ne pas modifier, et src/ArrayList.php avec ses onze signatures et sa méthode guardIndex() déjà écrite. À écrire : les onze corps de méthode.
@startuml
interface ListInterface {
+push(element: mixed): void
+get(index: int): mixed
+set(index: int, element: mixed): void
+remove(index: int): void
+indexOf(element: mixed): int
+includes(element: mixed): bool
+size(): int
+isEmpty(): bool
+clear(): void
+toArray(): array
+__toString(): string
}
class ArrayList {
#elements: array
#guardIndex(index: int): void
}
ListInterface <|.. ArrayList
note right of ListInterface : Un contrat dit ce qu'on peut faire, pas comment.
@enduml
Une liste indexée de 0 à size() - 1, sans trou. C'est la contrainte importante : après remove(1), l'ancien index 2 devient l'index 1. Tout ce qui suit se décale. Le # devant elements et guardIndex est la visibilité protected du chapitre 6 : la classe qui héritera d'ArrayList à l'étape 3 doit lire le tableau.
$compagnie = new ArrayList();
$compagnie->push('Arthur');
$compagnie->push('Perceval');
$compagnie->push('Merlin');
$compagnie->remove(1);
echo $compagnie->get(1), PHP_EOL;
echo $compagnie->size(), PHP_EOL;
echo $compagnie->indexOf('Perceval'), PHP_EOL;
echo $compagnie, PHP_EOL;
Merlin
2
-1
[
"Arthur",
"Merlin"
]
Ce que les tests attendent
| Méthode | Comportement vérifié |
|---|---|
push(), get(), set() |
Les éléments s'ajoutent à la fin et se relisent par index. |
remove() |
Retire l'élément et décale les suivants. Aucun trou dans les index. |
indexOf() |
Rend l'index de l'élément, et -1 s'il est absent. La comparaison est stricte : "2" n'est pas 2. |
includes(), size(), isEmpty(), clear(), toArray() |
Les cinq autres méthodes du contrat. |
__toString() |
Exactement la sortie de json_encode() avec les options JSON_PRETTY_PRINT et JSON_UNESCAPED_UNICODE, comme ci-dessus : une valeur par ligne, les accents tels quels. |
| index invalide | get(), set() et remove() lèvent OutOfRangeException hors des bornes, une autre exception prête à l'emploi de PHP. |
Attention
Le piège de indexOf(). array_search($element, $this->elements, true) cherche bien avec ===, mais renvoie false quand l'élément est absent, pas -1. Comme la méthode est déclarée : int et le fichier declare(strict_types=1), PHP refuse ce retour :
Fatal error: Uncaught TypeError: Collections\ArrayList::indexOf(): Return value must be of type int, false returned
C'est à vous de traduire le false en -1. Sans declare(strict_types=1) en tête de fichier, PHP convertirait false en 0 sans rien dire, et votre liste répondrait que l'absent est en première position.
Indice : array_splice() retire un élément et renumérote les suivants. La méthode guardIndex() est fournie : appelez-la au début de get(), set() et remove().
Vérification : composer test -- --group=etape-2, 18 tests verts.
Question : ListInterface ne contient aucune ligne de code, seulement des signatures. À quoi sert un fichier pareil ?
6. Une fonction passée en paramètre
Les exemples qui suivent travaillent sur le sac d'Arthur. La classe Item vient des tests du dépôt, dans tests/Fixtures/Item.php. C'est la version courte de l'Item du début du chapitre 5 : un nom, un poids, tous deux public readonly, sans la validation du poids. Vous écrivez donc $item->weight sans accesseur.
final class Item
{
public function __construct(
public readonly string $name,
public readonly float $weight,
) {}
}
L'étape 3 repose sur une idée : une fonction est une valeur comme une autre. Vous pouvez la ranger dans une variable ou la passer en paramètre. Une fonction passée à une autre pour être appelée par elle s'appelle un callback.
$crier = fn (string $nom): string => mb_strtoupper($nom) . ' !';
echo $crier('Arthur'), PHP_EOL;
ARTHUR !
fn (paramètres): type => expression est la fonction fléchée. Une seule expression, dont le résultat est renvoyé sans return. C'est la forme que vous verrez partout dans Laravel. mb_strtoupper() met en majuscules, lettres accentuées comprises. La fonction fléchée utilise les variables du code qui l'entoure sans avoir à les déclarer :
$limite = 5.0;
$estLeger = fn (Item $item): bool => $item->weight < $limite;
echo $estLeger(new Item('Torche', 1.0)) ? 'oui' : 'non', PHP_EOL;
echo $estLeger(new Item('Bouclier', 6.0)) ? 'oui' : 'non', PHP_EOL;
oui
non

Dans une signature, le type d'un callback s'écrit callable : une valeur que PHP peut appeler comme une fonction. Vous le lirez dans les boîtes UML de l'étape 3.
L'écriture plus ancienne, function () { ... }, reste utile quand le corps fait plusieurs lignes. Mais elle ne capture pas les variables locales toute seule : il faut les lister après use.
Attention
Le piège du use oublié. Ici, $limite n'existe pas à l'intérieur de la fonction :
$limite = 5.0;
$estLeger = function (Item $item): bool {
return $item->weight < $limite;
};
echo $estLeger(new Item('Torche', 1.0)) ? 'oui' : 'non', PHP_EOL;
Warning: Undefined variable $limite in leger.php on line 4
non
PHP se contente d'un avertissement, traite $limite comme null, et répond non. Votre torche de 1 kg n'est plus légère. La version correcte s'écrit function (Item $item) use ($limite): bool { ... }.
Note
Histoire de PHP. Les fonctions fléchées fn sont arrivées en PHP 7.4, en 2019. Avant, il fallait écrire function ($x) use ($y) { return …; }, avec la liste explicite des variables emportées. Les fonctions qui reçoivent un callback sont bien plus anciennes : array_map() et array_filter() existent depuis PHP 4. La nouveauté n'est donc pas l'idée, c'est l'écriture courte.
7. Étape 3 : la Collection
Fourni : src/Collection.php, avec les treize signatures et un indice pour la plupart. À écrire : les treize corps de méthode, en commençant par make().
$butin = [
new Item('Épée longue', 3.5),
new Item('Potion de soin', 0.5),
new Item('Bouclier', 6.0),
new Item('Torche', 1.0),
];
@startuml
interface Countable {
+count(): int
}
interface IteratorAggregate {
+getIterator(): Traversable
}
class ArrayList {
#elements: array
+push(element: mixed): void
+get(index: int): mixed
+size(): int
+toArray(): array
+__toString(): string
}
class Collection {
+{static} make(elements: array): static
+map(callback: callable): static
+filter(callback: callable): static
+reduce(callback: callable, initial: mixed): mixed
+each(callback: callable): static
+first(callback: ?callable): mixed
+last(callback: ?callable): mixed
+sum(callback: ?callable): float|int
+pluck(key: string): static
+sortBy(callback: callable): static
+reverse(): static
+count(): int
+getIterator(): Traversable
}
ArrayList <|-- Collection
Countable <|.. Collection
IteratorAggregate <|.. Collection
note right of Collection : Une transformation rend une nouvelle Collection.
@enduml
Le trait plein au triangle vide {uml:generalisation} se lit Collection extends ArrayList. Vous ne réécrivez ni push, ni get, ni size, ni toArray, ni __toString. Tout est hérité. Collection n'ajoute que les transformations, plus count() et getIterator(), qui viennent des deux interfaces natives du chapitre 7. Deux types nouveaux dans ces boîtes : static désigne la classe sur laquelle la méthode est appelée, ici Collection. float|int se lit « un flottant ou un entier » : la somme de quatre entiers reste un entier.
La règle de l'étape
Une transformation ne modifie jamais la collection de départ. Elle en renvoie une nouvelle. Cela vaut pour map, filter, pluck, sortBy et reverse. each() est la seule exception : elle exécute le callback pour son effet, puis rend la collection elle-même.
$sac = Collection::make($butin);
$legers = $sac->filter(fn (Item $item): bool => $item->weight < 5.0);
echo $sac->size(), PHP_EOL;
echo $legers->size(), PHP_EOL;
4
3
Le sac d'origine n'a pas bougé. C'est ce qui rend le chaînage sûr : chaque maillon reçoit une collection et en produit une autre, sans modifier la précédente.
make(), la fabrique
Collection::make([...]) est une méthode statique : elle s'appelle sur la classe, pas sur un objet. Comme Dice::d6() au chapitre 1, et comme le collect([...]) de Laravel. Écrivez-la en premier, les autres méthodes s'appuient dessus. Pour map() et filter(), remplissez un nouveau tableau, puis rendez static::make($resultat) : static:: appelle make() sur la classe de l'objet courant, self:: l'appellerait sur la classe où le code est écrit. Ici les deux donnent une Collection.
map(), filter(), pluck()
map() transforme chaque élément et rend la collection des résultats. filter() garde les éléments pour lesquels le callback répond true. pluck('name') lit la propriété name de chaque objet, ou la clé name de chaque tableau.
$noms = Collection::make($butin)
->pluck('name')
->map(fn (string $nom): string => mb_strtoupper($nom));
echo $noms->get(0), PHP_EOL;
$poids = Collection::make($butin)
->filter(fn (Item $item): bool => $item->weight < 5.0)
->pluck('weight')
->sum();
echo $poids, PHP_EOL;
ÉPÉE LONGUE
5
Quatre lignes pour le poids des objets légers, et chacune fait une seule chose : sélectionner, extraire, additionner.

Countable et IteratorAggregate
Vous connaissez les deux depuis l'Inventory du chapitre 7 : count() et getIterator(), une ligne chacune, et getIterator() rend un ArrayIterator construit sur vos éléments.
echo count($sac), PHP_EOL;
foreach ($sac->sortBy(fn (Item $item): float => $item->weight) as $item) {
echo $item->name, ' (', $item->weight, ' kg)', PHP_EOL;
}
4
Potion de soin (0.5 kg)
Torche (1 kg)
Épée longue (3.5 kg)
Bouclier (6 kg)
reduce() : une seule valeur à partir de tous les éléments
reduce() promène un accumulateur, une variable qui garde le résultat intermédiaire. Le callback le reçoit en premier argument, et l'élément en second.
$phrase = Collection::make($butin)
->pluck('name')
->reduce(fn (string $acc, string $nom): string => $acc === '' ? $nom : $acc . ', ' . $nom, '');
echo $phrase, PHP_EOL;
Épée longue, Potion de soin, Bouclier, Torche
Le second argument, ici la chaîne vide, est la valeur de départ de l'accumulateur. Cet exemple ajoute le séparateur avant chaque nom sauf le premier. La virgule en trop du début de chapitre ne peut plus se produire.
Ce que les tests attendent
| Méthode | Comportement vérifié |
|---|---|
make() |
Remplit la collection depuis un tableau. Sans argument, elle est vide. |
map(), filter() |
Rendent une nouvelle collection, laissent l'originale intacte, et renumérotent les index à partir de 0. |
reduce() |
Le callback reçoit l'accumulateur, puis l'élément. |
each() |
Exécute le callback sur chaque élément et rend la collection elle-même. |
first(), last() |
Avec ou sans callback, et null quand rien ne correspond. |
sum() |
Additionne des nombres : 10 sur [1, 2, 3, 4], 0 sur une collection vide. Avec un callback, additionne ce qu'il renvoie : 11.0 sur le sac. |
pluck() |
Lit une propriété d'objet ou une clé de tableau. |
sortBy(), reverse() |
L'originale garde son ordre. |
Countable, IteratorAggregate |
count($collection) et foreach ($collection as $item) fonctionnent. |
| héritage | push(), size(), indexOf(), includes() et __toString() marchent sans être réécrits. |
Astuce
Si reduce() résiste, écrivez d'abord sum(). C'est un reduce() dont l'accumulateur part de 0 et dont l'opération est une addition.
Vérification : composer test -- --group=etape-3, 24 tests verts.
8. Le coût d'un algorithme
Vous avez deux façons d'atteindre un élément : par index avec get(), ou en parcourant avec indexOf(). Elles ne coûtent pas la même chose, et la notation Big O le dit en un symbole. Elle compte des étapes, pas des secondes, et ne garde que ce qui domine quand n grandit.
$noms = ['Arthur', 'Perceval', 'Merlin', 'Guenièvre'];
$premier = $noms[0]; // O(1) : une étape, quelle que soit la taille
foreach ($noms as $nom) { // O(n) : 4 étapes pour 4 éléments
}
foreach ($noms as $a) { // O(n²) : 16 étapes pour 4 éléments
foreach ($noms as $b) {
}
}
| Notation | Nom | Dans votre code |
|---|---|---|
| O(1) | constant | get(), push(), size() sur ArrayList |
| O(n) | linéaire | indexOf(), map(), filter(), sum(), et remove(), parce qu'array_splice() renumérote tout ce qui suit |
| O(n²) | quadratique | une boucle qui appelle indexOf() à chaque tour |
Deux boucles imbriquées qui parcourent chacune les n éléments donnent O(n²), et le coût grimpe très vite.
9. Côte à côte avec la Collection de Laravel
Comparons. En haut votre classe, en bas Illuminate\Support\Collection.
// Votre Collection
echo Collection::make($butin)
->filter(fn (Item $item): bool => $item->weight < 5.0)
->sum(fn (Item $item): float => $item->weight), PHP_EOL;
// Illuminate\Support\Collection
echo collect($butin)
->filter(fn (Item $item): bool => $item->weight < 5.0)
->sum(fn (Item $item): float => $item->weight), PHP_EOL;
5
5
Mêmes noms de méthodes, mêmes callbacks, même règle de la nouvelle collection. collect() est un raccourci pour new Collection(...), l'équivalent de votre make(). Pour lancer vraiment la seconde chaîne, l'exercice 5 installe le paquet de Laravel dans votre dépôt.
Note
Une vraie différence, qui vaut d'être connue. Le filter() de Laravel conserve les clés d'origine, alors que le vôtre renumérote à partir de 0. Sur le sac d'Arthur, Laravel rend les clés 0, 1 et 3, et votre classe rend 0, 1 et 2. D'où le ->values() que vous croiserez souvent dans du code Laravel : il remet les index à plat. sortBy() et reverse() suivent la même règle.
La vraie Collection a plus de cent cinquante méthodes de plus, comme groupBy ou chunk. Inutile de les mémoriser : vous avez de quoi les comprendre en lisant leur description.
10. Bonus : LinkedList
Même contrat, autre mécanique. Les éléments ne sont plus rangés côte à côte : chaque maillon, un Node, garde un élément et le maillon suivant. La classe est fournie, elle tient en deux propriétés :
final class Node
{
public function __construct(
public mixed $element = null,
public ?Node $next = null,
) {}
}

Lire le 50e élément demande donc de traverser les 49 précédents, soit O(n) au lieu de O(1). En revanche, retirer un maillon déjà atteint ne décale rien : le maillon précédent reçoit le suivant dans next. La méthode remove(int $index) reste O(n), parce qu'il faut d'abord atteindre le maillon. Les tests du bonus vérifient le même contrat que ceux de l'étape 2, en 14 tests analogues. Un paramètre typé ListInterface accepte une ArrayList comme une LinkedList, alors que tout diffère à l'intérieur.
À retenir
- Une transformation renvoie une nouvelle collection.
map,filter,sortByetreversene modifient jamais l'original, dans votre classe comme dans Laravel. - Un callback est une fonction passée en paramètre.
fn (…) => …utilise les variables autour d'elle.function (…) use (…) { … }demande de les lister. Collection extends ArrayList: vous héritez de ce qui existe déjà, vous n'ajoutez que ce qui est nouveau.- Les interfaces natives ouvrent la syntaxe de PHP à vos objets.
Countabledonnecount(),IteratorAggregatedonneforeach. - Big O compte les étapes, pas les secondes. Accès par index O(1), parcours O(n), boucles imbriquées O(n²).
Exercices
Sur papier
- Sans lancer le code, écrivez la sortie de
Collection::make($butin)->reverse()->first()->name, puis deCollection::make($butin)->sortBy(fn (Item $i): float => $i->weight)->last()->name. Vérifiez ensuite avecphp. - Classez en O(1), O(n) ou O(n²) :
Stack::peek(),ArrayList::includes(),LinkedList::get(), et une boucle sur une liste qui appelleincludes()sur une autre liste à chaque tour.
Atelier
- Obligatoire. Les groupes
etape-1àetape-3verts, poussés, vérification verte dans l'onglet Actions. - Obligatoire. Créez
examples/ouverture.phpet réécrivez-y le code de la section 1 avec votreCollection: cinq lignes, une constante pour le5, une virgule au bon endroit.php examples/ouverture.phpdoit afficher exactementLéger : Épée longue, Potion de soin, Torche (5 kg). - Au choix. Ajoutez
avg(?callable $callback = null): float, qui rend la moyenne et0.0sur une collection vide. Réutilisezsum()etcount(). Ajoutez deux tests dans un nouveau fichiertests/Unit/AvgTest.php, avec->group('etape-3'): le groupe affichera alors 26 tests. Laravel, lui, rendnullpour la moyenne d'une collection vide : notez la différence. - Au choix. Ouvrez https://laravel.com/docs/collections et repérez trois méthodes dont le nom suffit à deviner le comportement. Notez ce que vous pensez qu'elles renvoient, puis vérifiez. Gardez vos notes pour la séance 6.
- Au choix. Lancez
composer require --dev illuminate/collectionsdans votre dépôt, puis exécutez les deux chaînes de la section 9 dans un même fichier. Comparez ensuite les clés rendues par les deuxfilter()avecvar_dump(...->toArray()). - Bonus.
LinkedList, groupebonus.