Search by

orbis-cms / filter-page

noith

SEO rewrite pages for query-filtered catalog/listing combinations in Orbis CMS.

Package info

gitlab.com/orbis-cms/filter-page

Issues

pkg:composer/orbis-cms/filter-page

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

0.0.5 2026-10-13 02:22 UTC

This package is auto-updated.

Last update: 2026-10-12 23:23:24 UTC


README

OrbisCms\FilterPage\Models\FilterPage gives a query-parameter combination of an existing catalog/listing page (the anchor) its own canonical URL and its own editable SEO metadata, without turning every possible filter combination into a page. It reuses orbis-cms/core's Pageable/HasPage mechanism — a FilterPage has its own Page (uri, title, h1, meta description, OpenGraph, schema.org, revisions) exactly like Article or TemplatePage.

A FilterPage does not render its own content. It points at an anchor Page — the page whose listing is actually displayed — and stores a normalized query string. When a visitor reaches the FilterPage's own canonical uri, or an "ugly" query on the anchor's uri that matches a registered combination, the stored query is merged into the request and dispatched to the anchor's own registered PageHandler. The core package does not need to know what the query keys mean (color, city, brand, …) — it only stores and matches a flat key/value string.

Database Schema

The filter_pages table stores:

  • anchor_page_id: a foreign key to pages.id — the anchor whose listing is rendered.
  • query: the normalized query string, e.g. "color=red" or "tag=news&year=2024".

A unique index on (anchor_page_id, query) prevents two different FilterPage records from claiming the same combination for the same anchor. pages.uri is already unique at the core level, so the FilterPage's own uri needs no additional uniqueness guard.

The Model

use OrbisCms\FilterPage\Models\FilterPage;

$filterPage = new FilterPage([
    'anchor_page_id' => $categoryPage->id,
    'query' => 'color=red',
]);
$filterPage->save();

app(\OrbisCms\Core\Services\PageService::class)->createFor($filterPage);

FilterPage implements Pageable and uses HasPage. pageUriPrefix() returns the anchor page's own uri (blank for the homepage), and pageUriSeed() joins the query's values, in their stored order, with /. Combined by pageUriSeedForGeneration(), this produces category/red for the example above, or archive/news/2024 for a two-key combination — without a separate field for the editor to type a path into. If a different uri is needed after creation, use PageService::changeUri()/redirectUri(), the same as for any other Pageable model.

anchorPage(): BelongsTo resolves the anchor Page (not its pageable — call $filterPage->anchorPage->pageable for the underlying content model).

Query Normalization

OrbisCms\FilterPage\Contracts\QueryNormalizer turns an array of query parameters into canonical key/value pairs and the canonical string stored in filter_pages.query and used for matching:

interface QueryNormalizer
{
    /** @param array<string, mixed> $query
     *  @return array<string, string>
     */
    public function normalizeToArray(array $query): array;

    /** @param array<string, mixed> $query */
    public function normalize(array $query): string;
}

The default implementation (DefaultQueryNormalizer) drops array values, sorts keys alphabetically, and lowercases values. It serializes the resulting pairs with RFC 3986 URL encoding, producing a deterministic string regardless of input order or casing. Array-valued parameters (size[]=a&size[]=b) are excluded from normalization and matching entirely — a FilterPage only ever represents a combination of single-valued parameters, the same way catalog SEO rewrites commonly cover one attribute value per URL rather than a multi-select combination. Multi-select stays on the query string as a normal, uncovered case.

Replace the binding to change the rule (e.g. a different case-folding policy for non-slug-like values). A replacement implements both methods so matching can cheaply discard query keys which are not registered for the anchor:

$this->app->bind(QueryNormalizer::class, MyQueryNormalizer::class);

Resolution

There are two independent entry points. Both are read-only with respect to the application's own filtering logic — a FilterPage only ever substitutes query parameters before the application's existing Filterable/Sortable code runs.

Direct canonical uri

No extra wiring is needed when the FilterPage's own uri is served through the core's public fallback route (Route::orbisCmsPages()). A FilterPage is an ordinary Pageable model with an ordinary Page, so DefaultPageResolver finds it exactly like any other content page, and the package registers FilterPageHandler for it in ContentRegistry. The handler merges the stored query into the request and dispatches to the anchor's own registered handler — the anchor's handler never knows the request didn't originally carry those parameters, and the metadata shared with the view ($page, OpenGraph, schema.org, robots) remains the FilterPage's own, carrying its specific SEO content.

use OrbisCms\Core\Registries\ContentRegistry;
use OrbisCms\FilterPage\Http\FilterPageHandler;
use OrbisCms\FilterPage\Models\FilterPage;

// registered automatically by FilterPageServiceProvider
$handlers->register(FilterPage::class, FilterPageHandler::class);

If the anchor's listing is served by a route that does not go through the core's fallback route (for example, a dedicated application route matched before the fallback, as is common for path-style filter routes), the application dispatches the match itself instead — there is no handler to fall back on, since the request never reaches PageResolver in the first place:

use OrbisCms\FilterPage\Services\FilterPageService;

Route::get('/{category}/{colorSlug}', function (string $category, string $colorSlug, FilterPageService $filterPages) {
    $filterPage = /* look up by the FilterPage's own uri */;
    // dispatch via the anchor's handler the same way FilterPageHandler does
});

"Ugly" query redirect

The package also wraps the bound OrbisCms\Core\Contracts\PageResolver with FilterPageAwarePageResolver. After the inner resolver finds a content page (the anchor), it checks FilterPageService::matchForAnchor() for a registered combination matching the current request's query parameters; on a match, it returns a 301 to the FilterPage's own canonical uri instead of serving the anchor directly:

$this->app->extend(PageResolver::class, fn (PageResolver $inner, $app) =>
    new FilterPageAwarePageResolver($inner, $app->make(FilterPageService::class)));

matchForAnchor(Page $anchor, array $query): ?FilterPage is fully generic — it does not need to be told which query keys are meaningful for a given anchor. Before generating anything, it intersects the request's (scalar-valued) query keys with AnchorKeysCache::keysFor($anchor->id) — the set of keys that actually appear in some registered FilterPage for this anchor, cached under its own key (one small entry per anchor, not one blob for the whole table — a request only ever pays for the anchor it's resolving, regardless of how many anchors exist) and refreshed for the affected anchor(s) whenever a FilterPage is saved or deleted. Only this (small, admin-bounded) intersection ever reaches subset generation: it builds every non-empty subset of the relevant keys, normalizes each, and looks all of them up in a single WHERE anchor_page_id = ? AND query IN (...) query against the (anchor_page_id, query) index. This keeps the work bounded by how many distinct keys an anchor's own registered rules actually use (admin-controlled, typically a handful) rather than by how many parameters a request happens to carry (attacker-controlled — a request with dozens of unrelated tracking/pagination parameters is reduced to nothing before any subset is built). Extra, uncovered parameters in the request never block a match — they are simply carried over into the redirect's query string. When more than one registered combination matches (for example color=red and color=red&size=large both match a request carrying both), the most specific one — the one covering the most keys — wins.

Applications whose anchor route does not go through PageResolver call matchForAnchor()/redirectTo() directly from their own controller or middleware.

$match = $filterPages->matchForAnchor($anchorPage, $request->query());

if ($match) {
    return $filterPages->redirectTo($match, $request);
}

Installation

Publish the package migration, then run the application's migrations:

php artisan vendor:publish --provider="OrbisCms\FilterPage\FilterPageServiceProvider"
php artisan migrate

Admin

FilterPageListScreen lists the anchor, query, Page.status, and Page.uri; its publication controls reuse ManagesPagePublication. FilterPageEditScreen reuses the standard page-editing layouts (PageTabsLayout, PageLayout, PageOgLayout, PageUriListener) plus a FilterPageLayout with:

  • an anchor-page picker (Orchid's built-in Relation field, searching pages by title/uri);
  • a key/value Matrix field for the query, normalized through QueryNormalizer on save before being stored.

Building the query from the application's actual filters through a dedicated UI, rather than typing raw key/value pairs, is not part of this package; manual creation in Orchid is the only way to register a combination.

Routes, the Filter pages menu item, and the orbis-cms.filter-pages permission are registered automatically through OrbisCms\Core\Registries\PlatformRegistry — after publishing and running the migration, no routing changes are needed in the host application.