Imported from LaravelDaily/nativephp-larapackagehunt (
.agents/skills/nativephp-v3-to-v4-upgrade/SKILL.md). Install upstream withnpx skills add LaravelDaily/nativephp-larapackagehunt --skill nativephp-v3-to-v4-upgrade. Copyright stays with the author.
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.commay only publish a stale branch. Ifcomposer show nativephp/mobile-uilists one old dev branch, add a vcs repository forhttps://github.com/NativePHP/mobile-uiand list it FIRST — composer repos are canonical, so the first one providing the package wins.nativephp/mobilelives in themobile-airrepo (https://github.com/NativePHP/mobile-air), notNativePHP/mobile.mobile-uirequiresnativephp/mobile: ^4.0, and mobile'sdev-maincarries nobranch-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.php
before composer 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,
TailwindParserwhitelist,NativeComponentlifecycle, 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.phptheme (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 --italicdownloads Google Fonts straight intoresources/fonts/. This command ships inmobile-ui. Looking for it innativephp/mobileis a dead end no matter how current that package is.CopyFontsCommand(the plugin'scopy_assetsbuild hook) bundlesresources/fonts/*into each native project at build time. Fonts only appear after a realnative:run.- Register semantic aliases in
config/native-ui.phpfontsand use them in views (font="headline"). Thedefaultalias is the app-wide font and supersedes the legacyfont-familytypography token when both are set. --defaultsets 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 whennativephp_callisn't available, andNativeUIServiceProviderpushes from its ownboot(). If it no-ops, the native side keeps its defaults — alias map empty, font family "System" — so everyfont="…"silently falls back. Re-push once the container is fully booted:// app/Providers/NativeServiceProvider::boot() $this->app->booted(fn () => Theme::pushToNative()); - A
font=attribute needs a matching weight class. Withoutfont-extrabold/font-semiboldalongside 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:modelis LIVE by default — the opposite of Livewire 3. Add.blur/.debounce.Xmswhere the old code used deferred binding.- No built-in
validate(): manualValidator+public array $errors+@nativeError. - No query strings on native routes:
?foo=1becomes navigation data read via$this->data('foo')inmount(). wire:init→#[Lazy]+placeholder();x-intersectinfinite scroll →@endReached(throttle it); pull-to-refresh →refreshable/@refresh;wire:poll→#[Poll(ms)]; flux modals →native:bottom-sheet/native:modal; teleported nav actions → per-screennavigationOptions/bottomBar;hideTabBar→$hidesTabBar.- Traits calling Livewire's
$this->redirect(): add a screen-localredirect()proxy forwarding tonavigate()rather than editing the shared trait mid-migration; port it properly in cleanup. - Icons:
App\Iconsenums via:ios/:android; a raw SF Symbol / Material name string is accepted when an enum case is missing. - Event directives interpolate:
@tap="{{ $action }}({{ $id }})"works — enables parametrizable partials. - 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.NativeUIColumnRendereropens withif node.children.isEmpty { Color.clear }— background, width and height all discarded. Use<native:rect />(self-closing) for any solid fill or scrim band; it rendersRectangle().fill(...)fromstyle.bg_color.native:circle/native:lineare the equivalents for other shapes. - Absolute children are ANCHORED, never stretched.
NativeUIStackLayoutandFlexContainer.placeAbsoluteplace a child at its own measured size. Opposing insets do NOT imply a size the way they do in CSS, soabsolute inset-0yields 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, sobottom-0pins to the TOP andright-0anchors LEFT. Use the smallest real value (bottom-px) when you want flush. Negative insets deliberately overhang (-right-8bleed). - 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. Putaspect-[3/4]on the stack and give the imagew-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-*withfrom/via/to) andinset-*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
srcmust be an absolute on-device path —public_path('img/x.png'). A relativeimg/x.pngis 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_coloras#AARRGGBB— there is noprops.bg. uppercaseis applied by the renderer, so thetextprop keeps its original case.layout.positionis[top, right, bottom, left];layout.position_type === 1means absolute.assertElement($type, $matcher)+tree()for structural assertions; guardpositionwith anis_array/countcheck, 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 / leftoverwire:/flux:syntax. - Only once green: delete
app/Livewire,resources/views/livewire,resources/views/flux, legacyresources/views/components, and old Livewire test dirs. Rewrite generic smoke tests (assertSeeLivewire→ route-resolution orNative::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 inparse(), asdarkhas; a flatarray_mergelets 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), andcreateElement, 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.mdcontract 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
argsmay arrive as a JSON string — guard withtypeof args === 'string' ? JSON.parse(args) : args. - Cache-resume: fix a workflow script bug and resume with the same run ID; completed agents replay free.