query('sort')), minPrice: self::floatOrNull($request->query('price_min')), maxPrice: self::floatOrNull($request->query('price_max')), inStockOnly: $request->boolean('in_stock'), page: max(1, (int) $request->query('page', 1)), ); } /** * @param ?int $collectionId scope to a collection (category page); null = every product */ public function filters(?int $collectionId = null): ProductFilters { return new ProductFilters( collectionId: $collectionId, minPrice: $this->minPrice, maxPrice: $this->maxPrice, inStockOnly: $this->inStockOnly, ); } /** * The applied params as a clean array (defaults omitted), with `$overrides` * merged on top — pass `['key' => null]` to drop one. * * @param array $overrides * @return array */ public function query(array $overrides = []): array { return array_filter([ 'sort' => $this->sort?->value, 'price_min' => $this->minPrice, 'price_max' => $this->maxPrice, 'in_stock' => $this->inStockOnly ? 1 : null, 'page' => $this->page > 1 ? $this->page : null, ...$overrides, ], fn ($value) => $value !== null); } /** * Whether the listing is reordered/narrowed enough that it shouldn't be * indexed as its own page (the canonical still points at the bare listing * URL either way). A plain in-stock toggle is left indexable. */ public function isRefined(): bool { return $this->sort !== null || $this->minPrice !== null || $this->maxPrice !== null || $this->page > 1; } private static function floatOrNull(mixed $value): ?float { return is_numeric($value) ? (float) $value : null; } }