PHPackages                             middag-io/ui - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Templating &amp; Views](/categories/templating)
4. /
5. middag-io/ui

ActiveLibrary[Templating &amp; Views](/categories/templating)

middag-io/ui
============

MIDDAG UI contract builders — transport-agnostic PageContract system for contract-driven rendering

v0.6.3(1w ago)0401Apache-2.0PHPPHP ^8.2CI passing

Since May 12Pushed 4d agoCompare

[ Source](https://github.com/middag-io/middag-php-ui)[ Packagist](https://packagist.org/packages/middag-io/ui)[ Docs](https://github.com/middag-io/middag-php-ui)[ RSS](/packages/middag-io-ui/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (10)Dependencies (9)Versions (12)Used By (1)

middag-io/ui
============

[](#middag-ioui)

**Transport-agnostic PHP contract builders — describe a page once, render it anywhere.**

[Documentation](https://docs.middag.dev) · [What it does](#what-it-does) · [GitHub](https://github.com/middag-io/middag-php-ui)

[![CI](https://github.com/middag-io/middag-php-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/middag-io/middag-php-ui/actions/workflows/ci.yml) [![License: Apache 2.0](https://camo.githubusercontent.com/5b60841bea9e11d9d0b0950d690c9bc554e06385634056a7d5d62a15d1a4eabe/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4170616368655f322e302d626c75652e737667)](https://opensource.org/licenses/Apache-2.0) [![PHP](https://camo.githubusercontent.com/38db6e59e2b3b5169bd1aba5ff029b639e6246a3e64d07f8821c954a1202c3eb/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253545382e322d3737374242342e737667)](https://www.php.net/) [![PHPStan](https://camo.githubusercontent.com/2761aeebb3945f1ca4e4b0f156a71a3558c10dd6e3da83b1feb44f4d33013329/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230362d627269676874677265656e2e737667)](https://phpstan.org/)

[![Packagist Version](https://camo.githubusercontent.com/a27a193f45bc1a843053592ea67fa7c139850fd46ead99b3cba90a877a84e435/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d69646461672d696f2f75692e737667)](https://packagist.org/packages/middag-io/ui) [![Packagist Downloads](https://camo.githubusercontent.com/6e67924f8093c710a7562e37c0db194064262e72d2eaef24abfcd4f59cd7c813/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6d69646461672d696f2f75692e737667)](https://packagist.org/packages/middag-io/ui)

Transport-agnostic PHP contract builder system for MIDDAG's contract-driven UI. Produces `PageContract` (JSON) consumed by `@middag-io/react` via Inertia or any transport layer.

**Zero external dependencies.** PHP 8.2+ only.

**Open source — Apache-2.0.** The foundation layer of the MIDDAG framework stack (`ui` → `framework` → `moodle` / `wordpress` adapters; `@middag-io/react` on the client). Host-agnostic and transport-agnostic by design: this package knows nothing about Moodle, WordPress, or any transport — it only describes pages.

Note

**Documentation** — the MIDDAG open-source project lives at **[middag.dev](https://middag.dev)**; full docs will be at **[docs.middag.dev](https://docs.middag.dev)** *(coming soon)*.

Until the docs site is live, this README plus the `@api` docblocks in `src/` are the reference.

Important

**`0.6.0` — concern-first layout (BREAKING).** Every `Middag\Ui\*` FQN moved from the old stereotype-first layout (`Contract/ Data/ Builder/ Enum/ Schema/`) to **concern-first**: each concern (`Page`, `Form`, `Table`, `Block`, `Region`, `Action`, …) owns its interfaces + value objects + builders, with cross-cutting types under `Shared/{Data,Enum,Schema}`. The **wire JSON is unchanged** — only PHP namespaces moved. Consumers on `^0.5` must update imports (e.g. `Middag\Ui\Data\Fragment` → `Middag\Ui\Region\Fragment`, `Middag\Ui\Enum\FieldType` → `Middag\Ui\Shared\Enum\FieldType`). Pre-1.0, no compatibility shims.

---

What It Does
------------

[](#what-it-does)

Pages in the MIDDAG stack are described by a `PageContract` — a JSON document declaring shell, page metadata, layout template, regions, and blocks. This library provides the PHP side: builders that produce that document. The React side (`@middag-io/react`) consumes it to render the actual UI.

This means PHP never renders HTML for pages — it declares structure, and React renders.

---

Features
--------

[](#features)

FeatureWhat you get**Transport-agnostic contracts**Builders produce `JsonSerializable` → `PageContract` (JSON). No Inertia/transport dependency; works with any wire.**Zero dependencies**PHP `^8.2` only. Consumers inherit no transitive packages.**Host-agnostic**No Moodle / WordPress / `mform` / capability coupling. Host-specifics live in the adapter, never here.**3 levels of composition**Convention (`CrudBuilder`) → convention + overrides → free composition (`PageBuilder`).**CRUD convention builder**`index`/`create`/`edit`/`show` pages from an entity class: i18n titles, filters, search, per-action `capability` gating.**Block types**`denseTable`, `formPanel`, `detailPanel`, `metricCard`, `emptyState`, `statusStrip`, `activityTimeline`, `markdownPanel`, `cardGrid`, `actionGrid`, `linkList`, `chart`, `tabs` — via `BlockBuilder` or the fluent `RegionBuilder`.**Typed value objects**`final readonly` + `JsonSerializable`. camelCase wire keys, omit-empty payloads, immutable witters.**Partial fragments**Server-push slices: `Fragment`, `RegionUpdate`, `ActionResult` (push + pull), `ResourcePatch`.**Navigation tree**3-level `NavigationNode` (group / section / item), capability-filtered, drill-down + collapsible.**Form system**Contracts + VOs (`FieldDefinition`, `Condition`, `FormState`, `Section`, `Group`). Renderers live in adapters.**i18n intents**`Translatable` `{key, domain, params}`. The library never resolves translations — the client does.**Quality gates**Full test suite, high coverage, PHPStan L6, php-cs-fixer, Rector — enforced per commit.---

Three Levels of Composition
---------------------------

[](#three-levels-of-composition)

### Level 1 — Convention (CrudBuilder)

[](#level-1--convention-crudbuilder)

Full CRUD pages from an entity class name:

```
// index page with default columns, actions, pagination
$contract = PageBuilder::crud(Invoice::class)->build('index', [
    'rows' => $invoices,
    'pagination' => ['page' => 1, 'perPage' => 25, 'total' => 100, 'lastPage' => 4],
]);
```

### Level 2 — Convention + Overrides (CrudBuilder)

[](#level-2--convention--overrides-crudbuilder)

Customize columns, actions, layout without leaving the convention:

```
$contract = CrudBuilder::for(Invoice::class, slug: 'invoices')
    ->without('show')
    ->columns(['number', 'status', 'amount', 'due_date'])
    ->column('status', fn (array &$col) => $col['variant'] = 'badge')
    ->filters([new FilterDefinition(key: 'status', label: 'Status')])
    ->searchable()                        // or mark a column searchable in its configurator
    ->sort('due_date', 'asc')
    ->perPage(50)
    ->i18n(domain: 'local_app')          // titles as i18n intent; falls back to literal nouns
    ->label('Invoice', 'Invoices')        // or override the noun explicitly
    ->build('index', ['rows' => $invoices]);
```

The class basename is treated as a **singular** noun. The library never fabricates a plural: the slug defaults to the singular, and `for(class, slug:)`overrides it for a plural URL. Titles resolve via `->label()` (explicit) → `_plural` i18n convention → singular fallback; create/edit verbs are emitted as `crud_create`/`crud_edit` intents in a shared UI domain.

### Level 3 — Free Composition (PageBuilder)

[](#level-3--free-composition-pagebuilder)

Full control over every block and region:

```
$contract = PageBuilder::page('invoices.show')
    ->title('Invoice #1234')
    ->subtitle('Due Jan 31')
    ->shell('product')
    ->layout('split')
    ->breadcrumbs(fn ($bc) => $bc->item('Invoices', '/invoices')->current('#1234'))
    ->actions([
        PageBuilder::action('pay', 'Mark Paid', ActionTarget::request('/invoices/1234/pay'), ActionIntent::PRIMARY),
    ])
    ->region('content', [
        BlockBuilder::detailPanel('invoice.detail', $sections),
    ])
    ->region('aside', [
        BlockBuilder::activityTimeline('invoice.activity', $groups),
    ])
    ->build();
```

---

Inertia Props
-------------

[](#inertia-props)

When you need overlay or help panel metadata alongside the contract:

```
return $this->inertia('Page', PageBuilder::page('orders.create')
    ->title('New Order')
    ->overlay()
    ->help('Creating an order', 'Fill in the details below.')
    ->inspector('/api/products/{id}')
    ->toProps());
// toProps() returns: ['contract' => ..., 'overlay' => true, 'help' => [...], 'inspector' => ...]
```

---

Partial Fragments (server push)
-------------------------------

[](#partial-fragments-server-push)

A page can be served two ways, and both share one envelope contract (`ContractEnvelopeInterface`, carrying the same `version`):

1. **Full page in PHP** — `PageContract` declares the whole page (shell, layout, regions, blocks). The default.
2. **Page owned by the client, partial props from PHP** — when React owns the layout, the server returns a `Fragment`: one ready, self-describing slice of the contract (a block, a table, a region update, notifications) plus its routing `kind`. A fragment is a node of the contract with its own header, not a smaller page.

```
use Middag\Ui\Region\Fragment;
use Middag\Ui\Region\RegionUpdate;

// after a filter/paginate, swap a region's content without reloading the page
$fragment = Fragment::region(RegionUpdate::replace('orders', $block1, $block2));
// {version: '1', kind: 'region', payload: {region: 'orders', mode: 'replace', blocks: [...]}}
```

`RegionUpdate` modes: `replace` / `append` / `prepend` / `remove` (by key) / `update` (match by key).

Mutations return an `ActionResult`, which carries both update strategies — push and pull:

```
use Middag\Ui\Action\ActionResult;

return new ActionResult(
    fragments: [Fragment::table($tableConfig)],   // push: server already built the fresh piece
    refreshBlocks: ['sidebar'],                    // pull: client re-fetches these keys itself
);
```

A `ResourcePatch` rides along on a `Fragment` or `ActionResult` to push a partial change to preferences / capabilities / feature flags without resending the whole `PageResources`.

---

Block Types
-----------

[](#block-types)

Static factories in `BlockBuilder::`:

MethodReact Component`BlockBuilder::denseTable($key, $columns, $rows)`Dense data grid`BlockBuilder::formPanel($key, $action, $method, $schema, $values)`Form panel`BlockBuilder::detailPanel($key, $sections)`Read-only detail view`BlockBuilder::metricCard($key, $value, $label, $delta, $icon, $href)`KPI card`BlockBuilder::emptyState($key, $variant, $description, $cta)`Empty state`BlockBuilder::statusStrip($key, $items, $tone)`Status bar`BlockBuilder::activityTimeline($key, $groups, $hasMore, $loadMoreHref)`Activity feed`BlockBuilder::markdownPanel($key, $content, $maxHeight)`Markdown body`BlockBuilder::cardGrid($key, $columns, $rows, $variant)`Card grid`BlockBuilder::actionGrid($key, $items, $flash)`Action card grid`BlockBuilder::linkList($key, $items)`Link list`BlockBuilder::chart($key, $type, ChartSeries[], ...)`Chart (ChartType enum)`BlockBuilder::tabs($key, Tab[])`Tabs containerOr via `RegionBuilder` fluent API inside a `->region()` closure. Each method mirrors its `BlockBuilder` factory one-to-one, so the same block type produces an identical descriptor whichever entry point you use:

```
->region('content', function ($r) {
    $r->metricCard('revenue', 42000, 'Revenue', delta: '+8%')
      ->denseTable('orders', ['id', 'total'], $rows)
      ->emptyState('no-results', variant: 'filtered');
})
```

---

Navigation
----------

[](#navigation)

`NavigationNode` is the `@api` value object for nav tree entries. Serializes to the shape consumed by `SidebarNav` in `@middag-io/react`:

```
$node = new NavigationNode(
    key: 'audience.segments.index',
    label: 'Segments',
    icon: 'users',
    href: '/segments',
    active: true,
    weight: 10,
);
// Registered in AbstractNavigationRegistry implementations (in framework)
```

---

Form System
-----------

[](#form-system)

This library provides contracts and value objects only — renderers live in `middag-io/framework` (Inertia) and the host adapters.

### Contracts

[](#contracts)

InterfaceRole`FormInterface``schema()` → `hydrate()` → `validate()` → `validated()``FieldInterface``toDefinition(): FieldDefinition` — produces the boundary object`FormRendererInterface``target(): RenderTarget` + `render(Form): RendererOutput``LayoutElementInterface``id()` + `children()` — Section and Group implement this### Value Objects

[](#value-objects)

ClassNotes`FieldDefinition`Immutable boundary object between DSL and renderers. No `JsonSerializable` — renderers map manually.`Condition``field + operator (ConditionOperator enum) + value + kind`. Kinds: `visible_when`, `hidden_when`, `required_when`, `disabled_when`.`FormState`Immutable readonly VO. `withValues()` / `withErrors()` return a new instance. Carries `values`, `errors`, `submitted`.`RendererOutput`Static factories `::html()` and `::props()` for the two render targets.### Layout Primitives

[](#layout-primitives)

```
$section = Section::of('personal')
    ->label(Translatable::of('personal_info_section', 'forms')) // or a literal string
    ->fields($nameField, $emailField, Group::of('phone')->fields($countryCode, $number));
```

`Section::label()` takes a `string|Translatable` like every other label in the contract; `labelData()` serializes it via `Label` (a `{key, domain}` payload for an intent, a raw string for a literal).

### Field Types (FieldType enum)

[](#field-types-fieldtype-enum)

A closed backed enum of field types (`TEXT`, `TEXTAREA`, `SELECT`, `DATE`, `RICHTEXT`, `TIME`, `AUTOCOMPLETE`, `TAGS`, …) — see `src/Shared/Enum/FieldType.php`for the full catalogue.

Adding a type requires a new field class, matching renderer-side mappers, and the client component — most of which live downstream.

---

Table Builder
-------------

[](#table-builder)

Fluent API for producing `TableConfig` consumed by dense table blocks:

```
$config = TableBuilder::make()
    ->column('name', 'Name', ['sortable' => true, 'searchable' => true])
    ->column('amount', 'Amount', ['sortable' => true, 'format' => ValueFormat::CURRENCY, 'formatOptions' => ['currency' => 'BRL']])
    ->filter('status', 'Status', FilterType::SELECT, [
        ['value' => 'active', 'label' => 'Active'],
        ['value' => 'inactive', 'label' => 'Inactive'],
    ])
    ->rowAction(PageBuilder::action('edit', 'Edit', ActionTarget::link('/invoices/{id}/edit')))
    ->bulkAction(PageBuilder::action('delete', 'Delete', ActionTarget::request('/invoices/bulk/delete'), ActionIntent::DANGER))
    ->options(new TableOptions(perPage: 25, sortColumn: 'name', selectable: true))
    ->build();
```

---

Installation
------------

[](#installation)

Requires PHP `^8.2`. Install via Composer:

```
composer require middag-io/ui
```

---

Development
-----------

[](#development)

```
composer install
composer test           # PHPUnit
composer check          # style + rector + stan
composer fix            # style + rector (apply)
composer lint:php82     # parse every file (incl. tooling configs) on real PHP 8.2 (Docker)
```

The dev toolchain may run a newer PHP than the supported floor (`^8.2`). Newer syntax such as `new X()->method()` parses on 8.4 but is a fatal error on 8.2, so `composer check` alone will not catch it. Two guards keep the floor honest: `composer lint:php82` runs `php -l` under a real PHP 8.2 interpreter over the source, tests, and the `.php-cs-fixer.dist.php` / `.php-rector.php` configs; and the CI **Static analysis &amp; style** job runs entirely on PHP 8.2. PHPStan is configured for the `8.2–8.4` range for version-sensitive type checks (it does not catch syntax-level issues — that is the lint's job).

Git hooks configured automatically via `post-install-cmd`. `commit-msg` enforces Conventional Commits.

```
type(scope): description

Types: feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert

```

Releases managed by [release-please](https://github.com/googleapis/release-please).

---

License
-------

[](#license)

Licensed under the Apache License, Version 2.0. See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).

```
Copyright 2026 MIDDAG

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

```

Contributing
------------

[](#contributing)

See [`CONTRIBUTING.md`](CONTRIBUTING.md).

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance99

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 86.2% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

9

Last Release

8d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/6410303?v=4)[Michael Douglas Meneses de Souza](/maintainers/michaelmeneses)[@michaelmeneses](https://github.com/michaelmeneses)

---

Top Contributors

[![michaelmeneses](https://avatars.githubusercontent.com/u/6410303?v=4)](https://github.com/michaelmeneses "michaelmeneses (100 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (16 commits)")

---

Tags

uiinertiaformcontract-builderpage-contracttransport-agnostic

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/middag-io-ui/health.svg)

```
[![Health](https://phpackages.com/badges/middag-io-ui/health.svg)](https://phpackages.com/packages/middag-io-ui)
```

###  Alternatives

[craue/formflow-bundle

Multi-step forms for your Symfony project.

7614.0M13](/packages/craue-formflow-bundle)[kartik-v/yii2-widget-select2

Enhanced Yii2 wrapper for the Select2 jQuery plugin (sub repo split from yii2-widgets).

33710.0M196](/packages/kartik-v-yii2-widget-select2)[robsontenorio/mary

Gorgeous UI components for Livewire powered by daisyUI and Tailwind

1.5k531.0k21](/packages/robsontenorio-mary)[sonata-project/form-extensions

Symfony form extensions

10915.9M46](/packages/sonata-project-form-extensions)[kartik-v/yii2-widget-activeform

Enhanced Yii2 active-form and active-field with full bootstrap styling support (sub repo split from yii2-widgets).

647.6M62](/packages/kartik-v-yii2-widget-activeform)[a2lix/auto-form-bundle

Automate form building

873.9M13](/packages/a2lix-auto-form-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
