Laravel: How the AsCollection::of() saves tons of lines
A simple PR will save dozens of unnecessary keystrokes
One great thing about Eloquent Mutators included with Laravel is the AsCollection
class. By adding it as a cast to a Model attribute, we can quickly retrieve a list of items as a Collection instead of a plain array.
The problem with that are the items themselves. When having a list of simple scalar items like booleans, strings or even other arrays, there is not much to do. If your items should be object instances of a determined class, that’s when the problem arises.
What we had to do until now
Let’s imagine we have a list of Cars. Each item in the list is another list with the Car brand, model and color. This list is persisted into each “Driver” model.
use App\Models\Driver;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Model;
class Driver extends Model
{
public function casts()
{
return ['cars' => AsCollection::class];
}
}
$driver = Driver::find(1)
$driver->cars = Collection::make([
['brand' => 'Porsche', 'model' => 'Cayman', 'color' => 'red'],
['brand' => 'Hyundai', 'model' => 'Elantra', 'color' => 'gray'],
['brand' => 'Nissan', 'model' => 'Z', 'color' => 'yellow'],
]);
The problem is that we need each item to be mapped into a determined Car
class, back and forth the model. Trying make this work will require two alternatives: creating our own cast method, or use our own Collection instance.
// Using a Attribute Cast Method
use App\ValueObjects\Car;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Collection;
class Driver extends Model
{
protected function cars(): Attribute
{
return Attribute::make(
get: fn($value) => Collection::make($value)->mapInto(Car::class),
set: fn($value) => json_encode($value->toJson),
);
}
}
use App\ValueObjects\Car;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Support\Collection;
class CarCollection extends Collection
{
public function __construct($items = [])
{
parent::__construct(
array_map(fn($value) => new Car($value), $items)
);
}
}
class Driver extends Model
{
public function casts()
{
return ['cars' => AsCollection::using(CarCollection::class)];
}
}
Both of these solutions are overkill just to map each item into an object instance. That’s why I pushed a simple PR to Laravel to do just that with the AsCollection
cast.
AsCollection with object instances
The AsCollection
class got a new static method called of()
. It accepts two types of arguments that differ on how each item would be created.
- Issuing a class name will use the
mapInto()
method of the Collection to create new instances based on the raw item value. - Issuing a callable as an array will use the
map()
method of the Collection, returning what the callable does as an Collection item.
The first is easy to understand. For example, our Car
class will receive the decoded array and create an instance from it.
class Car
{
public string $brand;
public string $model;
public string $color;
public function __construct(array $data)
{
$this->model = $data['brand'];
$this->model = $data['model'];
$this->model = $data['color'];
}
}
class Driver extends Model
{
public function casts()
{
return ['cars' => AsCollection::of(Car::class)];
}
}
This may suffice for simple classes, but if you require special treatment on how the Item should be constructed, then the callback is a great way to do it. In this case, we can create a static method on the Car
class and call it for each item.
For example, let’s say our Car
class is not constructed with an array, but using each attribute separately, since it’s also done in that way across the application. Instancing a class won’t work since mapInto
will push only the array and the numeric key as argument to instance the class. In that case, we can use a method called fromArray()
that takes care of it.
class Car
{
public function __construct(
public string $brand;
public string $model;
public string $color;
)
{
//
}
public static function fromArray(array $data, int $key): static
{
return new static($data['brand'], $data['model'], $data['color']);
}
}
class Driver extends Model
{
public function casts()
{
return ['cars' => AsCollection::of([Car::class, 'fromArray'])];
}
}
Both approaches are fine and will depend on the application needs.
Personally, I like more adding a fromArray()
static method to the classes I need to instance from an Item array, but that’s my preference and may sound too complicated for simple objects with a few values, especially if your items are based on Illuminate\Support\Fluent
instances.