Вы уже из-за подхода Laravel начали во всю использовать статики: Card::change Log::error
class CartController extends Controller
{
    //...
    public function change($productId, $action)
    {
        try {
            $session_id = session()->getId();
            $cart = Cart::bySession($session_id)
                ->firstOrCreate(['session' => $session_id]);
            $cart->change($productId, $action);
            
        } catch(\Exception $e) {
            // Здесь можно разрешить как не стандартные ситуации, так и стандартные.
            // Но у нас пока одно.
            Log::error('Cart change: ' . $e->getMessage(), [$productId, $action]);
            return back()
                ->with('message', 'Простите, дяденька, засранца.');
        }
        // Возвращаемся туда от куда добавился товар или изменилось его количество
        return back();
    }
}namespace App;
use Illuminate\Database\Eloquent\Model;
use App\Cart\ActionException;
class Cart extends Model
{
    // ...
    public function scopeBySession($query, $sessionId)
    {
        return $query->where('session', '=', $sessionId);
    }
    public function change($productId, $action)
    {
        switch($action) {
            case 'append':
                $this->processAppend($productId);
                break;
            case 'reduce':
                $this->processReduce($productId);
                break;
            case 'clear':
                $this->processClear($productId);
                break;
            default:
                throw new ActionException($action, $this->session, $productId);
        }
    }
    private function processAppend($productId)
    {
        // ...
    }
    // ...
}Это абсолютно не рашсряемо. Если у вам нужно подменить логгер в зависимости от окружения. Задача, кстати, очень частая, когда подменяют именно логер.
Card::change - вы жестко завязались на метод change который что-то делает.
Проблема целостности логики приложения — тут не видно ни связи текущего пользователя с корзиной. Потенциальная уязвимость того, что вы можете привязать товар ни к той корзине. Если один экшен, это не так критично. А представьте, если таких мест десятки. Как пример амазон. Там различных способов работы с корзиной кучи.
Проблема расширяемости. Вы не можете подменить спокойно реализацию корзины для разных пользователей. Например, если у юриков и у физиков разные способы размещения заказов в ней (если правильно помню, тоже из амзмона пример)
Вы, скажете, что это не проблема Laravel. А я скажу, что именно его. Потому что он не предоставляет своей методики, как правильно получать экземпляры, кроме как через запрос в контроллере через статические методы подключения к БД.
Далее. Как вы собираетесь прикручивать ко всему этому гибкие права. Например, у пользователя с ролью менеджера есть права только на чтение.
class CartChange extends FormRequest
{
    // НАПРИМЕР, ОДНА ИЗ ТОЧЕК ПРОВЕРКИ ПРАВ!!!
    public function authorize()
    {
        return true; // Если в данном контексте мы этим не замарачиваемся
    }Теперь возвращаемся к коду. В CartChange много чего завязано на какие то ассоциативные массивы и куча публичных методов. Я не понимаю как это работает. Это вообще процедурное программирование. Тут нет реализации каких то интерфейсов, для понимания, что можно передать, а чего нельзя.
Это сплошное нарушение SOLID, так как CartChange нарушает принцип единой ответственности. Он предоставляет различные данные разной с разной природой.
Даже передавать разный request для каждого action попахивает. Меняем строчку в роутинге и вам приходит другой объект. Потенциальное место огрести кучу неявных ошибок.
<!-- Вариант с параметрами в URL -->
<form action={{ route('cart.change', ['action' => 'append', 'product' => $produt->id]) }} method="post">
    {{ csrf_field() }}
    <button type="submit">+</button>
</form>
<span>{{ $cart->productQuantity($produt->id) }}</span>
<form action={{ route('cart.change', ['action' => 'reduce', 'product' => $produt->id]) }} method="post">
    {{ csrf_field() }}
    <button type="submit">-</button>
</form>
<!-- Вариант с CartChange Параметры в форме  -->
<form action={{ route('cart.change') }} method="post">
    {{ csrf_field() }}
    <input type="hidden" name="action" value="append"> 
    <input type="hidden" name="product" value="{{ $produt->id }}"> 
    <button type="submit">+</button>
</form>
<span>{{ $cart->productQuantity($produt->id) }}</span>
<form action={{ route('cart.change']) }} method="post">
    {{ csrf_field() }}
    <input type="hidden" name="action" value="reduce"> 
    <input type="hidden" name="product" value="{{ $produt->id }}"> 
    <button type="submit">-</button>
</form>Вы вообще на что это пишите? Я вас спрашиваю, поверх чего создаются слои и для чего.
// ...
Route::post('cart', [
    'as'   => 'cart.change',
    'uses' => 'CartController@change',
]);<?php namespace App\Http\Controllers;
// Для папки App\Http\Requests\ валидируемые запросы идут из коробки, 
// но требуют определения
// Например, реализуем проверку параметров сортировки
use App\Http\Requests\CartGet; 
// Здесь проверяем product_id и action
use App\Http\Requests\CartChange; 
// Наши реализации для DDD, которые работают с ActiveRecord
use App\Domain\Cart\GetRequest;
use App\Domain\Cart\ChangeRequest;
use Illuminate\Support\Facades\Log;
class CartController extends Controller
{
    public function show(CartGet $request)
    {
        $cart_request = new GetRequest($request);
        $cart = $cart_request->process();
        return view('cart.show')
            ->with('cart', $cart);
    }
    public function change(CartChange $request)
    {
        try {
            $cart_request = new ChangeRequest($request);
            $cart_request->process();
        } catch(\Exception $e) {
            // Здесь можно разрешить как не стандартные ситуации, так и стандартные.
            // Но у нас пока одно.
            Log::error('Cart change: ' . $e->getMessage(), $request);
            return back()
                ->with('message', 'Простите, дяденька, засранца.');
        }
        // Возвращаемся туда от куда добавился товар или изменилось его количество
        return back();
    }
}<?php namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CartChange extends FormRequest
{
    public function authorize()
    {
        return true;
    }
    public function rules()
    {
        return [
            'product_id' => 'required|exists:products,id', 
            'action'     => 'required|in:append|reduce|clear',
        ];
    }
    public function messages()
    {
        return [
            'product_id.required' => 'Не выбран товар.',
            'product_id.exists'   => 'Товар не доступен.',
            'action.required'     => 'Не выбрано действие над товаром',
            'action.in'           => 'Не верное действие над товаром',
        ];
    }
}Вы хоть раз принимали участие в большой разработке?
Route::get('products', [
    'as'   => 'products.show',
    'uses' => 'ProductController@products',
]);
Route::get('product/{product}/', [
    'as'   => 'product.show',
    'uses' => 'ProductController@product',
])->where('product', '[0-9]*');
Route::get('cart', [
    'as'   => 'cart.show',
    'uses' => 'CartController@show',
]);
Route::post('cart/{product}/{action}', [
    'as'   => 'cart.change',
    'uses' => 'CartController@change',
])->where([
    'product' => '[0-9]*',
    'action'  => 'append|reduce|clear',
]);<?php namespace App\Http\Controllers;
use App\Product;
use App\Cart;
class ProductController extends Controller
{
    const ITEMS_ON_PAGE = 10;
    public function products()
    {
        $products = Product::orderBy('price', 'desc')
            ->paginate(self::ITEMS_ON_PAGE);
        if (!count($products)) {
            // Как вариант, решать в отображении что сообщить, 
            // так как может не быть товаров вообще
            abort(404);
        }
        return view('product.list')
            ->with('products', $products)
            ->with('cart', Cart::summary());
    }
    public function product($productId)
    {
        $product = Product::find($productId);
        if (!$product) {
            abort(404);
        }
        return view('product.item')
            ->with('product', $product)
            ->with('cart', Cart::summary());
    }
}<?php namespace App\Http\Controllers;
use App\Cart;
use Illuminate\Support\Facades\Log;
class CartController extends Controller
{
    public function show()
    {
        return view('cart.show')
            ->with('cart', Cart::all());
    }
    public function change($productId, $action)
    {
        try {
            // Изменение корзины внутреннее дело самой корзины
            Cart::change($productId, $action);
        } catch(\Exception $e) {
            // Здесь можно разрешить как не стандартные ситуации, так и стандартные.
            // Но у нас пока одно.
            Log::error('Cart change: ' . $e->getMessage(), [$productId, $action]);
            return back()
                ->with('message', 'Простите, дяденька, засранца.');
        }
        // Возвращаемся туда от куда добавился товар или изменилось его количество.
        // А именно страница товара, списка товаров или корзина.
        return back();
    }
}> Читайте вопрос автора.
> Поверх чего? Поверх ActiveRecord? Зачем он тогда нужен во фреймворке, если есть DataMapping?
> Значит Larvel не написан на основе Symfony. А написан с использованием компонентов Symfony. Но, суть Фреймворка именно в предложении концепции программисту как строить своё приложение.
Так что решать проблему целостности данных, вы будете частично в приложении. И так делают 99% разработчиков.
Эра быстрых прототипов закончилась, где то в 2012. Сейчас уже нужны комплексные решения.
За счёт DI объект будет создан сам. Но можно передать нечто ручками. По этому можем тестировать имитируя Database. (Для фриланса и плюшевых проектов TDD в игноре, но всё же актуален.)
Даже для плюшевых проектов порой смена Database нужна. Впрочем у меня был доступ не к БД, а к сайту через запрос xml файла с парсингом айтемов. Сделал в виде репозитория. Далее через пару месяцев сервис отдававший (кривой xml) создал API. Правка репозитория оказалась быстрой.
А ещё через неделю потребовалось для других URL другой сервис, но в те же айтемы. Другой репозиторий. Получаем его через фабрику, которая отдаёт и первый, но зависимо от URL ресурса. Часть которая работала с самими айтемами не изменилась нисколько.