Docs/Codex/PHP SDK

PHP SDK

KAT\Codex\Sdk\Codex is the server-side surface used inside api.katforge.com (controllers, EmberService, LevelService, internal commands). Same identifier grammar, same response shape as the HTTP API — but reads go straight through the repositories to Postgres, so there's no HTTP round-trip.

Last reviewed May 6, 2026

Construct

The SDK is a Symfony service. Inject it like any other:

PHP
use KAT\Codex\Sdk\Codex;

final class ItemController extends AbstractController
{
   public function __construct (private readonly Codex $codex) {}

   public function show (string $id): JsonResponse
   {
      $sword = $this->codex->dnd ()->item ($id);

      return $this->json ($sword);
   }
}

Codex

Root entry point. Resolve a Game instance and proceed from there.

game($slugOrAbbreviation, $branch = 'main')

Canonical accessor. Returns a Game bound to a specific game and branch.

PHP
$dnd = $codex->game ('dark-and-darker');
$qa  = $codex->game ('dark-and-darker', branch: 'map.lich_king');

Game abbreviation methods

Driven by data/codex/games.yaml — adding a game adds the abbreviation method automatically. The current set:

PHP
$codex->dnd ();         // dark-and-darker
$codex->wc3 ();         // wc3
$codex->mecha ();       // mechabellum
$codex->gg ();          // gear-goblins
$codex->lex ();         // lextris
$codex->kat ();         // katforge

// Same as $codex->game ('dark-and-darker', 'map.lich_king'):
$codex->dnd ('map.lich_king');

These are syntactic sugar — each one is the same call as game('<slug>') with the right slug, generated from data/codex/games.yaml at boot. Calling ->{'dark-and-darker'}() is awkward syntax; use game('dark-and-darker') instead.

dictionary()

Returns the underlying Games registry. Useful for iterating every registered game (e.g. listing them in admin UI).

PHP
foreach ($codex->dictionary ()->all () as $entry) {
   echo "{$entry ['slug']}  ({$entry ['abbreviation']})\n";
}

Game

Returned by Codex::game() / ->dnd() / etc. Per-game read surface.

Single-entity accessors

Each accepts either the canonical id (id.<type>.…) or the upstream key (Id_<UpstreamPrefix>_…). The id form is type-checked at the call site — passing id.skill.heavy_swing to ->item() throws TypeMismatchException rather than silently returning a row of the wrong shape.

PHP
$dnd = $codex->dnd ();

// Typed accessor — returns Item (subclass of Entity) with extra getters.
$sword = $dnd->item ('id.item.longsword_5001');
$sword?->name;                    // "Longsword"
$sword?->rarity;                  // "epic"
$sword?->gear_score;              // 50

// Upstream key works too:
$sword = $dnd->item ('Id_Item_Longsword_5001');

// Generic accessors — return Entity:
$cock     = $dnd->merchant ('id.merchant.cockatrice');
$bless    = $dnd->spell    ('id.spell.bless');
$religion = $dnd->religion ('id.religion.faith_of_the_seven');
$prop     = $dnd->itemProperty ('id.item_property.primary_longsword_5001');

// Untyped lookup — useful when iterating generic links:
$row = $dnd->entity ('id.merchant.cockatrice');

The full set of typed accessors:

text
item, skill, spell, perk, monster, quest, dungeon, religion, merchant, itemProperty

For everything else, entity($id) returns the base Entity wrapper.

patches($limit = 50)

Patch list newest first. Each row carries id, version, version_raw, imported_at, plus the join to parent_patch_id for forks.

PHP
$recent = $codex->dnd ()->patches (limit: 10);

foreach ($recent as $p) {
   echo "{$p ['version']}  {$p ['imported_at']}\n";
}

currentPatch()

The head patch on this game + branch — the same row entity_resolved_current reads from.

PHP
$head = $codex->dnd ()->currentPatch ();
$head ['version'];                // "0.16.137+build.8750"

diff($from, $to)

Patch-to-patch delta. Returns a Diff (or null if either patch doesn't exist on this branch).

PHP
$delta = $codex->dnd ()->diff ('0.16.135.8645-2663', '0.16.137.8750');

$delta?->summary;                 // [ 'added' => 12, 'changed' => 184, 'removed' => 3 ]
$delta?->changed ('item');        // every item that changed

See Diff below for the full surface.

assetMeta($hash)

Asset row by hash — (hash, media_type, bytes). Memoized per-instance to avoid re-fetching when an entity wrapper resolves its icon multiple times.

PHP
$meta = $codex->dnd ()->assetMeta ('22cd8c50…');
$meta ['bytes'];                  // 8868

Escape hatches

PHP
$dnd->repository ();              // EntityRepository — for queries the SDK doesn't cover
$dnd->patchRepository ();         // PatchRepository — same

Entity

Generic entity wrapper. Per-type subclasses (currently Item) extend it with typed accessors. Construction is private to the SDK — callers go through Game::item() etc.

Public properties

These are genuinely tabular — every entity row carries the same shape:

FieldTypeNotes
$idstringCanonical id, e.g. id.item.longsword_5001.
$typestringLowercase type slug (item, skill, …).
$gamestringGame slug.
$branchstringBranch slug, default main.
$namestring | nullLocale-resolved display name.
$tagsstring[]
$dataarray<string, mixed>Per-game projection, mostly verbatim from upstream.
$linksarray<{ rel, target }>Resolved cross-references.
$iconRefarray{ hash, url } | nullRaw primary-icon ref.
$assetRefsarray<{ role, hash, url, media_type, bytes }>Multi-asset list.

icon()

Returns an Asset for the primary icon (the role=icon entry, or first overall if no role is named). null when there's no asset list.

PHP
$sword = $codex->dnd ()->item ('id.item.longsword_5001');

$icon = $sword->icon ();
$icon?->url ();                                // "/v1/codex/assets/22cd8c50…"
$icon?->url (size: '256x256');                 // CDN-hinted variant
$icon?->url (size: '256x256', format: 'webp'); // ?size=256x256&format=webp

assets()

Every asset attached to this entity, keyed by role for direct slot access. Multi-entry roles (e.g. several portraits) collapse to the first — use assetList() to keep duplicates.

PHP
$cock = $codex->dnd ()->merchant ('id.merchant.cockatrice');

$slots = $cock->assets ();
$slots ['icon']?->url ();
$slots ['portrait']?->url ();

assetList()

Ordered list, duplicates preserved. Use when an entity legitimately has multiple of one role:

PHP
foreach ($cock->assetList () as $asset) {
   echo "{$asset->mediaType}  {$asset->url ()}\n";
}

at($version)

Snapshot at a prior patch on the same branch. Returns the same wrapper type (an Item stays an Item).

PHP
$sword = $codex->dnd ()->item ('id.item.longsword_5001');

$old = $sword->at ('0.16.134.8542');
$old?->gear_score;                // 50  (vs. 55 on the current patch)

history()

Every patch that touched this entity, oldest first.

PHP
foreach ($sword->history () as $row) {
   echo "{$row ['patch_version_raw']}  {$row ['content_hash']}\n";
}

diff_since($fromVersion)

Single-entity diff between an older patch and the current one. Same shape as a Diff entry but scoped to this entity.

PHP
$changes = $sword->diff_since ('0.16.134.8542');
$changes ['op'];                  // 'changed'
$changes ['changes'];             // [ { path, before, after }, ... ]

Item

Subclass of Entity with typed magic getters over the upstream-shape data blob:

PHP
$sword = $codex->dnd ()->item ('id.item.longsword_5001');

$sword->archetype;       // "weapon"
$sword->slot;            // "primary"
$sword->hand;            // "two_handed"
$sword->weapon_type;     // "sword"
$sword->rarity;          // "epic"
$sword->gear_score;      // 50
$sword->inventory;       // [ 'w' => 1, 'h' => 4 ]
$sword->is_tradable;     // true

The raw $sword->data blob stays accessible for forensic queries that need the upstream shape. The mapping table — useful for figuring out where each getter pulls from:

GetterSource pathExample
archetypedata.item_type enum leaf"weapon"
slotdata.slot_type tail"primary"
handdata.hand_type tail"two_handed"
weapon_typedata.weapon_types[0] tail"sword"
raritydata.rarity_type tail"epic"
gear_scoredata.gear_score50
adv_pointdata.adv_point12
exp_pointdata.exp_point5
inventory{ w, h }[ 'w' => 1, 'h' => 4 ]
is_tradabledata.tradabletrue

Asset

Content-addressed binary asset (icon, portrait, audio).

Public fields

PHP
$asset->hash;        // SHA-256, 64 hex chars
$asset->mediaType;   // "image/png", "image/webp", "audio/ogg", ...
$asset->bytes;       // file size in bytes
$asset->width;       // ?int — reserved for future backfill
$asset->height;      // ?int — reserved for future backfill

url($size = null, $format = null)

Builds the public URL. Without params returns the canonical bytes; with params appends CDN resize / format hints.

PHP
$icon->url ();                                    // /v1/codex/assets/<h>
$icon->url (size: '256x256');                     // ?size=256x256
$icon->url (size: '256x256', format: 'webp');     // ?size=256x256&format=webp

The CDN serves resized / reformatted variants by query string; the canonical URL without params is the source of truth.

Diff

Returned by Game::diff(). Wraps a list of EntityDiff entries with fluent op + type filters.

Public fields

PHP
$delta->entries;     // EntityDiff[] — every entity in the diff
$delta->summary;     // [ 'added' => N, 'removed' => N, 'changed' => N ]

added($type = null) / changed($type = null) / removed($type = null)

Filter by op, optionally by type. Returns EntityDiff[].

PHP
$delta = $codex->dnd ()->diff ('0.16.135.8645-2663', '0.16.137.8750');

$delta->changed ();              // every changed entry
$delta->changed ('item');        // every item that changed
$delta->added ('skill');         // skills introduced this patch
$delta->removed ('quest');       // quests retired this patch

entity($id) and type sugar

Single entry by full id. null if the entity didn't appear in this delta.

PHP
$delta->entity ('id.item.longsword_5001');     // EntityDiff or null
$delta->item   ('id.item.longsword_5001');     // sugar — same call
$delta->skill  ('id.skill.bash');
$delta->spell  ('id.spell.bless');

EntityDiff

One row of a Diff. Field-level changes for op === 'changed'; before/after snapshots for added/removed.

Public fields

PHP
$row->id;             // 'id.item.longsword_5001'
$row->type;           // 'item'
$row->op;             // 'added' | 'removed' | 'changed' | 'unchanged'
$row->changes;        // [ { path, before, after }, ... ]
$row->before;         // mixed — full snapshot for 'removed' / 'changed'
$row->after;          // mixed — full snapshot for 'added' / 'changed'

$row->changed_paths;  // string[] — paths where both before and after exist
$row->added_paths;    // string[] — paths that appeared (before === null)
$row->removed_paths;  // string[] — paths that disappeared (after === null)

The *_paths buckets are derived from changes so call sites that only care about "what fields appeared / disappeared / mutated" don't have to re-classify every entry themselves.

PHP
$row = $delta->item ('id.item.longsword_5001');

$row->op;                         // 'changed'
$row->changed_paths;              // [ 'data.gear_score', 'data.primary.physical_damage.max' ]

foreach ($row->changes as $c) {
   echo "{$c ['path']}: {$c ['before']} → {$c ['after']}\n";
}

SDK-as-implementation

Controller\Codex\Entities is a thin JSON wrapper around Codex::entity(). So the PHP SDK and the HTTP API never drift — the SDK is the API's read implementation. If you find a discrepancy, it's a bug in the wrapper, not a divergence in semantics.