Purpose

The query surface a theme uses to walk the page tree. $site->pages->findActiveByParent(0) for a top-level nav, findBySlug('about') for a one-off lookup, levels() for a sidebar tree, descendants($parent) for a flat blog-archive list. Returns Page DTOs throughout so templates never deal in raw iManager Items.

A handful of write methods (save, delete, reorderOne, slugTaken, wouldCreateCycle, nextPosition) live on the same class. They power the editor's Pages module. Themes typically do not call them; they are documented here for completeness, not for encouragement.

FQCN + file path

When to use

Templates and theme methods reach it through $site->pages. A plugin can also pull it directly from the container:

$pages = $container->get(\Scriptor\Boot\Frontend\PageRepository::class);

The repository is request-scoped and cheap to read repeatedly, but does not memoise: every find() call hits the iManager ItemRepository. Long traversals should fetch once and iterate the returned array, not call find() in a loop.

Surface

Read

public function find(int $id): ?Page
public function findBySlug(string $slug): ?Page
public function findHome(): ?Page
public function findAll(): array  // list<Page>

find() returns null when no page with that id exists or when the id resolves to an item in a different category. findBySlug() is LIMIT 1; if you have collisions (e.g. two pages with the same slug under different parents), use findByParent() filtered. findHome() is the canonical lookup for the page that owns / (the page whose slug is the empty string).

public function findByParent(
    int $parentId,
    string $orderBy = 'position',
    Direction $direction = Direction::Asc,
    bool $activeOnly = false,
    int $offset = 0,
    int $limit = 0,
): array

Children of one parent in the configured order. Direction is Imanager\Query\Direction::{Asc,Desc}. $limit = 0 means no limit. $activeOnly = true filters out items where the editor toggled the page off.

public function findActiveByParent(int $parentId): array

Convenience for the canonical navigation shape: active pages, position ASC, no offset, no limit. The default renderNavigation() on Site uses this for the top-level menu.

public function findInTimeRange(
    int $start,
    int $end,
    int $parentId,
    string $orderBy = 'created',
    Direction $direction = Direction::Desc,
): array

Active pages under $parentId whose created timestamp falls in [$start, $end). Drives the bundled basic theme's monthly blog archive.

public function countByParent(int $parentId, bool $activeOnly = false): int

Count without materialising the result list. Useful for "N posts in this section" badges and for pagination math.

Tree walks

public function levels(
    int $rootParent = 0,
    int $maxDepth = 0,
    bool $activeOnly = true,
    array $excludeIds = [],
): array

Materialises a tree of pages keyed by parent id. The return type is array<int, list<Page>>: each key is a parent id, each value is the active children of that parent. $maxDepth = 0 walks until exhausted; $maxDepth = 2 stops after two levels of nesting. Replaces the 1.x Pages::getPageLevels().

The walk is cycle-safe: a self-parented page or a loop in the parent chain is detected and the recursion bails out without infinite-looping.

public function descendants(Page $parent): array

Flat list of every active descendant beneath $parent, parents-first. Equivalent to a depth-first walk of levels() followed by a flatten, optimised for callers that only want the sequence.

Slug / cycle guards (mostly admin-side)

public function slugTaken(string $slug, int $parentId, ?int $exceptId = null): bool
public function wouldCreateCycle(int $pageId, int $candidateParentId): bool
public function nextPosition(): int

These exist for the editor's Pages module to preflight a save before committing. A theme normally has no reason to call them. Documented so a plugin that ships its own page-creation UI has the right invariants in scope.

slugTaken() ignores $exceptId so a page can keep its own slug on update. wouldCreateCycle() walks the candidate parent's existing chain upwards; if it passes through $pageId, the save would close a loop.

Write (admin-side)

public function save(Item $item): Page
public function delete(int $id): void
public function reorderOne(int $movedId, array $idsInOrder): void
public function renumber(array $idsInOrder): void

save() accepts a raw Imanager\Domain\Item (not a Page) because writes are owned by the iManager item lifecycle. The returned Page reflects the persisted state. delete() is idempotent: deleting a non-existent id is a no-op.

reorderOne() is the interactive drag-reorder path: the editor sends the moved id plus the full new visual order, and the repository repositions just that one row (cascade-shifting following rows only when adjacent positions touch). renumber() is the legacy bulk path that overwrites every page's position from the supplied id list; kept for seed imports.

Read-only public state

public readonly int $categoryId;

The numeric Pages category id, resolved from the categorySlug = 'pages' constructor argument. Exposed so a plugin that wants to query items in the Pages category directly through iManager (e.g. for a custom field aggregation) does not have to re-resolve the slug.

Lifecycle

Container-bound, request-scoped singleton. One instance per request, constructed from (CategoryRepository $categories, ItemRepository $items, string $categorySlug = 'pages'). The constructor calls categories->findBySlug($categorySlug) once and stores the resulting id; subsequent reads use that id directly.

If the configured category slug is not present in the database (e.g. a brand-new install before bin/scriptor install ran), construction raises RuntimeException. In normal operation the slug is always present because install seeds it.

Common patterns

Top-level navigation

foreach ($site->pages->findActiveByParent(0) as $entry) {
    $url   = $site->siteUrl . '/' . $site->getPageUrl($entry);
    $title = $entry->menu_title !== '' ? $entry->menu_title : $entry->name;
    echo sprintf(
        '<li><a href="%s">%s</a></li>',
        htmlspecialchars($url, \ENT_QUOTES),
        htmlspecialchars($title, \ENT_QUOTES),
    );
}

Blog archive (children of a "blog" parent, paginated)

$blog = $site->pages->findBySlug('blog');
if ($blog === null) {
    return;
}
$posts = $site->pages->findByParent(
    $blog->id(),
    orderBy: 'created',
    direction: \Imanager\Query\Direction::Desc,
    activeOnly: true,
    offset: ($page - 1) * 10,
    limit: 10,
);
$tree = $site->pages->levels(
    rootParent: $site->page->parent,
    maxDepth:   2,
    activeOnly: true,
);
// $tree[$parentId] => list<Page> children

Walk every descendant (e.g. sitemap generator)

$root = $site->pages->findHome();
if ($root !== null) {
    foreach ($site->pages->descendants($root) as $page) {
        echo $site->getPageUrl($page), "\n";
    }
}

See also