Upgrade a NativePHP Mobile v3 app (Livewire/Volt/Flux in a web view) to v4 native SuperNative EDGE UI. Covers the composer/package moves the docs don't mention, the plugin rename, fonts and theme-push timing, the silent-failure layout traps, testing, and cutover. Activate when a project is on nativephp/mobile v3, when composer requires nativephp/native-ui, when native screens render as system font or blank/overlapping boxes, or when the user asks to upgrade/migrate to v4.
Upgrade a NativePHP Mobile v3 app (Livewire/Volt/Flux in a web view) to v4 native SuperNative EDGE UI. Covers the composer/package moves the docs don't mention, the plugin rename, fonts and theme-push timing, the silent-failure layout traps, testing, and cutover. Activate when a project is on nativephp/mobile v3, when composer requires nativephp/native-ui, when native screens render as system font or blank/overlapping boxes, or when the user asks to upgrade/migrate to v4.
NativePHP Mobile v3 → v4 Upgrade
Battle-tested from migrating WhipRate mobile (18 screens, 21 Livewire components, full Flux Pro UI) to
100% native EDGE with 700+ passing tests. Follow the phases in order.
The defining property of v4 debugging: almost every failure is SILENT. Unsupported classes are
dropped, unsized nodes render nothing, a theme push that arrives too late leaves the system font in
place — none of it errors. When something looks wrong, assume "dropped silently" before "styled
wrong", and verify against source rather than reasoning about it.
Phase 0 — Setup (get this wrong and everything fails)
The package moved, and the marketplace lags
The UI component library was renamed, and this is not in most docs:
v3 / older
v4
nativephp/native-ui
nativephp/mobile-ui
Nativephp\NativeUi\
Native\Mobile\UI\
type nativephp-ui-plugin
type nativephp-plugin
Three composer facts that block a naive composer require:
plugins.nativephp.com may only publish a stale branch. If
lists one old dev branch, add a repository for
and list it FIRST — composer repos are canonical, so the first one providing the package wins.
composer show nativephp/mobile-ui
vcs
https://github.com/NativePHP/mobile-ui
nativephp/mobile lives in the mobile-air repo (https://github.com/NativePHP/mobile-air),
not NativePHP/mobile.
mobile-ui requires nativephp/mobile: ^4.0, and mobile's dev-main carries no
branch-alias, so the solver rejects it. Use an inline alias:
"nativephp/mobile": "dev-main as 4.0.99".
mobile-ui also raises the iOS minimum to 18.2. Check your deployment target before upgrading.
Register the plugin — installing is not enough
php artisan native:plugin:register nativephp/mobile-ui, verify with native:plugin:list. The app's
app/Providers/NativeServiceProvider::plugins() imports the provider by FQCN, so the namespace
change has to be applied there too. Device builds then need a user-run native:run (never run build
commands yourself — always ask which platform first).
Without the UI plugin, nativephp/mobile ships the EDGE runtime but NOT the component library
(text/image/button/list/inputs, bottom-sheet, layout builders, Theme, config/native-ui.php, icon
tooling) — every element throws "Unknown native element type".
Removal ordering for Livewire packages
Strip VoltServiceProvider (and any Livewire-touching provider) from bootstrap/providers.phpbeforecomposer remove livewire/flux-pro livewire/flux livewire/volt livewire/livewire —
composer remove triggers package:discover, which boots the app and fatals on missing classes.
Also delete the flux-pro entry from composer.json repositories.
Trust source over docs
Package READMEs and bundled skill docs can be ahead of the shipped code — v4 docs referenced
php artisan native:font months before any package shipped it. The truth is
vendor/nativephp/mobile-ui/src/, vendor/nativephp/mobile/src/Edge/, and the renderers under
resources/ios/ + resources/android/. If a documented command is missing, check which package it
belongs to before assuming your install is broken.
Phase 1 — Understand (before writing any screen)
Produce three docs (parallel agents if orchestrating):
API reference from vendor source: element catalog, TailwindParser whitelist, NativeComponent
lifecycle, routing macros, layout builders, testing API.
Screen inventory: per routed screen — state seeding (exact request/cache/DTO classes), every
action, events, navigation, UI outline, Livewire mechanics needing translation, covering tests.
Document non-routed embedded components (modals, permission managers) under the parent screens that
must absorb them.
Design plan: trace the real palette/typography out of the Tailwind CSS into a
config/native-ui.php theme (light+dark for every role), plus tab/stack layout designs.
Phase 2 — Scaffold (foundation before fan-out)
Order: publish native-ui config → fill theme+fonts → native-ui:generate-icons → write NativeLayout
classes (tabs / stack / form-stack / guest; theme() for all chrome colors, no hex) → rewrite
routes/web.php with Route::native()/Route::nativeGroup() preserving every URI and route name →
generate ONE component with native:make to prove path conventions → write a conventions.md
contract doc (namespaces, paths, layout assignments, theme tokens, gotchas) that all converters read.
Route order matters: static segments before {param} siblings (/cars/create before /cars/{id}).
Routes may reference not-yet-existing classes — registration is string-based, the app still boots.
Fonts — the single most confusing part of the upgrade
The webview pulled fonts from a CDN. Native cannot. The whole custom-font path lives in
mobile-ui, not nativephp/mobile:
php artisan native:font Inter --weights=400,600,700,900 --italic downloads Google Fonts straight
into resources/fonts/. This command ships in mobile-ui. Looking for it in
nativephp/mobile is a dead end no matter how current that package is.
CopyFontsCommand (the plugin's copy_assets build hook) bundles resources/fonts/* into each
native project at build time. Fonts only appear after a real native:run.
Register semantic aliases in config/native-ui.phpfonts and use them in views (font="headline").
The default alias is the app-wide font and supersedes the legacy font-family typography
token when both are set.
--default sets the app-wide alias — but it uses the LAST file it downloaded, which is often an
italic. Check the value it wrote.
Two traps that both present as "my font isn't working" (text renders as SF Pro / Roboto, upright):
The theme push can fire before the bridge is reachable.Theme::pushToNative() no-ops when
nativephp_call isn't available, and NativeUIServiceProvider pushes from its own boot(). If it
no-ops, the native side keeps its defaults — alias map empty, font family "System" — so every
font="…" silently falls back. Re-push once the container is fully booted:
A font= attribute needs a matching weight class. Without font-extrabold / font-semibold
alongside it, the face collapses to the regular weight.
Colors working is NOT evidence the theme push works.bg-theme-* classes are resolved to literal
hex per node in PHP, so colors look correct even when the bridge push never lands. That mismatch is
exactly what makes this read as a font-config problem instead of a theme-push one.
Phase 3 — Shared partials before screens
Convert shared cards/skeletons/buttons/sheets to resources/views/native/partials/ FIRST, with a
PartialsTest.php harness proving each renders. Document the host contract (required vars, action
names the host screen must implement, refs for tests) in conventions.md. Screen converters then
@include('native.partials.X') — parent public props propagate into includes automatically.
Phase 4 — Convert screens (parallelizable, one agent per screen)
Strict file-ownership: each converter owns its component class, view, screen sub-partials, and test
file; NEVER edits routes, config, layouts, shared partials, or the data layer. Blockers get reported,
not fixed inline. Keep the entire data layer (Saloon requests, caches, DTOs, services) untouched.
Livewire → EDGE translation
native:model is LIVE by default — the opposite of Livewire 3. Add .blur/.debounce.Xms
where the old code used deferred binding.
No built-in validate(): manual Validator + public array $errors + @nativeError.
No query strings on native routes: ?foo=1 becomes navigation data read via $this->data('foo')
in mount().
Traits calling Livewire's $this->redirect(): add a screen-local redirect() proxy forwarding
to navigate() rather than editing the shared trait mid-migration; port it properly in cleanup.
Icons: App\Icons enums via :ios/:android; a raw SF Symbol / Material name string is
accepted when an enum case is missing.
Missing native capability (e.g. HEIC conversion the webview did)? Flag for a plugin decision —
never fall back to the web view.
Layout traps that render NOTHING and never error
These cost the most time. All verified from renderer source.
An empty <native:column> paints nothing.NativeUIColumnRenderer opens with
if node.children.isEmpty { Color.clear } — background, width and height all discarded. Use
<native:rect /> (self-closing) for any solid fill or scrim band; it renders
Rectangle().fill(...) from style.bg_color. native:circle / native:line are the equivalents
for other shapes.
Absolute children are ANCHORED, never stretched.NativeUIStackLayout and
FlexContainer.placeAbsolute place a child at its own measured size. Opposing insets do NOT imply
a size the way they do in CSS, so absolute inset-0 yields an intrinsic-sized child at top-left —
nothing at all for a rect. To fill a stack: absolute top-0 left-0 w-full h-full.
A zero inset means "no anchor on that edge." The packed node struct has no spare byte to
distinguish an unset edge from an explicit 0, so bottom-0 pins to the TOP and right-0
anchors LEFT. Use the smallest real value (bottom-px) when you want flush. Negative insets
deliberately overhang (-right-8 bleed).
Aspect ratio goes on the STACK, not the image. A remote image's natural size is tiny, so
<native:stack class="w-full"><native:image class="aspect-[3/4]"> leaves the stack collapsed and
the image overflows every sibling below it. Put aspect-[3/4] on the stack and give the image
w-full h-full.
Unsupported Tailwind classes are dropped in silence. Verify before assuming:
TailwindParser::parse('the-class') returning [] means unsupported. Known gaps: blur-*,
scale-*, per-corner radii (rounded-t-xl — only uniform keys parse), max-w-* / min-w-*.
Gradients (bg-gradient-to-* / bg-linear-to-* with from/via/to) and inset-* ARE
supported as of 2026-07.
leading-* maps to SwiftUI .lineSpacing — space BETWEEN lines. It is a no-op on single-line
text, and iOS cannot tighten below the font's natural line height. Adjust padding instead.
Image src must be an absolute on-device path — public_path('img/x.png'). A relative
img/x.png is handed to AsyncImage/Coil, which can't resolve it, and renders blank.
Tests are the safety net
Port every legacy Livewire test to Native\Mobile\Testing\Native::test()/Native::visit() with
FakeBridge + Saloon MockClient. Cover happy/failure/weird paths (401→login redirect, offline,
empty feeds). Add ref attributes to interactive elements for test targeting.
Wire-format facts that make assertions land first time:
The test harness renders through createElement, a different code path from the device's
streaming collector. A prop can work in tests and be missing on device, or vice versa.
Fills live at style.bg_color as #AARRGGBB — there is no props.bg.
uppercase is applied by the renderer, so the text prop keeps its original case.
layout.position is [top, right, bottom, left]; layout.position_type === 1 means absolute.
assertElement($type, $matcher) + tree() for structural assertions; guard position with an
is_array/count check, since not every node carries one.
Phase 5 — Integrate + cutover
Integrator: fix reported blockers, verify every route resolves (NativeRouter->resolve() returns
['class','layout','params']), run the full native suite plus untouched data-layer suites, and
grep native views for hex literals / non-whitelist classes / leftover wire:/flux: syntax.
Only once green: delete app/Livewire, resources/views/livewire, resources/views/flux, legacy
resources/views/components, and old Livewire test dirs. Rewrite generic smoke tests
(assertSeeLivewire → route-resolution or Native::visit()->assertScreen()).
composer dump-autoload, full suite, vendor/bin/pint --dirty, php artisan native:validate.
Remind the user to rebuild — plugin registration, plugin Swift/Kotlin sources AND bundled fonts all
compile in at build time.
Patching the framework during an upgrade
Local path checkouts of mobile / mobile-ui are common on this path. If you add a style utility,
it must travel three layers, and the collector has four call sites — missing one produces the
classic "works in tests, missing on device" split:
mobile/src/Edge/TailwindParser.php — class → attrs. A feature spanning several classes (like
gradients) needs a deep-merge branch in parse(), as dark has; a flat array_merge lets the
last class clobber the earlier ones.
mobile/src/Edge/NativeElementCollector.php — attrs → wire. Sites: openStreaming,
leafStreaming (each with a builtin and a plugin branch), and createElement, which is what
the test harness uses.
NodeStyleModifier.swift / NodeModifiers.kt — the single choke point where every node's
background is painted; wiring there makes a utility work on any element.
The node is a packed 160-byte binary struct with fixed offsets, so the style block cannot take
variable-length data — ride the props bag instead (dark_bg_color does). Unknown prop keys are
fine: the encoder falls back to an inline string for anything outside the interned PropKey table.
Orchestration notes (multi-agent)
The conventions.md contract doc is what makes parallel conversion safe — write it before fan-out,
append the partials contract to it.
Effort scaling: complex screens (multi-step forms, camera, geolocation) get high effort; simple
lists don't.
Workflow args may arrive as a JSON string — guard with
typeof args === 'string' ? JSON.parse(args) : args.
Cache-resume: fix a workflow script bug and resume with the same run ID; completed agents replay free.