Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
nativephp-mobile
nativephp/mobile
claude-code
laravel
android
ios
nativephp

Builds fully native iOS and Android apps with PHP & Laravel. Activate when working with SuperNative screens (NativeComponent, Route::native, native:make), nested child components (component tags, props, key, emit, @event bindings), EDGE components (native:column, native:button, native:list, and 40+ more), composable chrome elements (top-bar, bottom-nav, fab, side-nav, bottom-bar) or NativeLayout chrome, native:model data binding, #[Computed]/#[Poll]/#[On] attributes, Native::test() component tests, native device APIs (camera, dialog, biometrics, scanner, geolocation, push notifications), NativePHP Artisan commands (native:run, native:install, native:watch, native:jump), build output paths (.app/.apk) and build traps, deep links, secure storage, or mobile app deployment.

Install
php artisan boost:add-skill nativephp/mobile

Save this content to: .claude/skills/nativephp-mobile/SKILL.md

# NativePHP Mobile v4

NativePHP Mobile v4 ("SuperNative") builds **fully native** apps: SwiftUI on iOS, Jetpack Compose on Android,
driven entirely by PHP. Each screen is a `NativeComponent` PHP class (think Livewire, but for native views)
rendering **EDGE** Blade elements. There is no web server and — for native UI screens — no web view.

## Native UI First — Non-Negotiable

**Every screen you build or scaffold must be native UI**: a `NativeComponent` + EDGE elements, registered with
`Route::native()`.

- Never create new screens as web views, Livewire pages, or Inertia pages. The web view is a legacy escape hatch
  (`native:web-view`) for embedding web content inside a native screen — never the foundation of one.
- If the user asks for a webview-based screen, build it natively with EDGE and briefly explain why (performance,
  platform look-and-feel, no web-view lifecycle issues). Only fall back if they explicitly insist.
- When you encounter existing webview/Livewire/Inertia screens in an app, suggest migrating them — the
  `nativephp-webview-to-native` skill walks through the conversion.
- Style exclusively with Tailwind utility classes via `class="..."` / `:class="..."`. Never inline CSS
  `style="..."` or ad-hoc styling props.
- **Prefer theme tokens and font aliases over raw values.** Publish `config/native-ui.php`
  (`vendor:publish --tag=native-ui-config`), define the palette in its `theme` block and semantic font
  aliases in `fonts` (`'headline' => 'ArchivoNarrow-Bold'`), then style with `bg-theme-*` / `text-theme-*` /
  `border-theme-*` classes and `font="headline"`. The token map is open-ended (add `success`,
  `outline-variant`, … to both blocks and `bg-theme-success` just works) and theme classes accept opacity
  modifiers (`bg-theme-primary/15` — the tonal-fill idiom). Arbitrary `bg-[#…]` values are only for genuine
  data-driven color (category identity colors, imagery) and belong in one PHP home (enum/model), never inline
  per view.
- Use `native:icon` for iconography (SF Symbols on iOS, Material Icons on Android — cross-platform names like
  `home` resolve on both). Never use emoji characters in UI text, labels, or buttons unless the user explicitly
  asks for them. Prefer the **typed icon enums** (`App\Icons\Ios`, `App\Icons\Android`, `App\Icons\AndroidOutlined`,
  bound with `:ios` / `:android` — they're autocompletable and can't misspell a symbol name. The enums are
  generated, not shipped: **if `app/Icons/` doesn't exist yet, run `php artisan native-ui:generate-icons`
  first** (safe to run yourself; re-run with `--refresh-material` to update). Import them with `@use` (compiled
  views have no namespace), or use fully-qualified cases:

```blade
  @use('App\Icons\Ios')
  @use('App\Icons\Android')

  <native:icon :ios="Ios::Gearshape" :android="Android::Settings" :size="28" class="text-theme-primary" />
  ```

## Documentation

Before implementing any feature, fetch the relevant docs using `WebFetch`. Find the right URL in
[references/available-docs.md](references/available-docs.md).

```
WebFetch("https://nativephp.com/docs/mobile/4/the-basics/routing", "Explain Route::native, navigation methods, and transitions")
```

## Build Commands — Run Them Yourself

Check behaviour with `Native::test()` first (see Testing). When a change needs a build to take effect or to be
checked, run `native:run` yourself against a booted simulator or running emulator. The details, output paths and
traps are under Building and Running below. If no simulator or emulator is available, say so and give the user
the command.

```bash
php artisan native:run ios <UDID> --no-tty -n    # or android <SERIAL>; build, install, launch, then exit
php artisan native:run ios --watch               # build, deploy, hot reload: never exits
php artisan native:watch                         # hot reload only: never exits
php artisan native:jump                          # device dev loop via the Jump app (QR code)
./native run                                     # shortcut wrapper installed by native:install
```

Ask first, or leave to the user: `--watch` and `native:watch` never exit, so don't block on them (start one in the
background only when the user wants hot reload). Release builds, signing, `native:package` and store uploads are
the user's call.

For a build-and-verify loop with machine-readable output (`native:run ... --json`, `native:devices`,
`native:status`, `native:screenshot`, `native:tail`, `native:open-url`), follow the `nativephp-agent-loop` skill.

The Vite dev server is **opt-in** in v4: add `--vite` to `native:run`/`native:watch` only when the app uses
JS/CSS HMR (web-view assets). Native UI screens hot-reload without Vite. `npm run build -- --mode=ios|android`
is only needed for apps that still ship web-view assets.

## Getting Started

New app from the starter kit:

```bash
laravel new my-app --using=nativephp/mobile-starter --no-node
```

The starter already has `nativephp/mobile-ui` installed and registered in `app/Providers/NativeServiceProvider.php`,
a `routes/mobile.php`, an example `app/NativeComponents/Home.php` with `tests/Feature/NativeHomeTest.php`, Pest,
and it has run `native:install` for you. When you replace the Home screen, delete or rewrite that test (it asserts
the welcome text).

Existing Laravel app:

```bash
composer require nativephp/mobile nativephp/mobile-ui
php artisan vendor:publish --tag=nativephp-plugins-provider   # creates app/Providers/NativeServiceProvider.php
php artisan native:plugin:register nativephp/mobile-ui
php artisan native:plugin:list                                # must show mobile-ui as registered
php artisan native:install both                               # after setting the .env keys below
```

Then create `routes/mobile.php` (it is loaded automatically). `mobile-ui` is not optional: core has no renderers,
so every element, even `column` and `text`, comes from it.

Set env vars **before** `php artisan native:install`:

```dotenv
APP_NAME="My App"                     # the display name on both platforms
NATIVEPHP_APP_ID=com.yourcompany.yourapp
NATIVEPHP_APP_VERSION="DEBUG"         # the version number, e.g. 1.2.0
NATIVEPHP_APP_VERSION_CODE="1"        # the build number
# Optional for iOS:
NATIVEPHP_DEVELOPMENT_TEAM=XXXXXXXXXX
```

The name, app ID and version are applied again on every build, so they can be changed later. Keep
`NATIVEPHP_APP_VERSION=DEBUG` while developing (see Building and Running below).

OS support: macOS builds iOS + Android; Windows/Linux build Android only; WSL unsupported.

## SuperNative Screens

Scaffold with `php artisan native:make Counter` (remove with `native:rm`). Register screens in
`routes/mobile.php`. The package loads it automatically, but only in native contexts (on device, under Jump,
in `native:*` commands and in tests), so a web deploy of the same app never exposes them:

```php
Route::native('/', Home::class);
Route::native('/item/{id}', ItemDetail::class);
```

If the app already loads `routes/mobile.php` itself, the package leaves it alone and it isn't gated. Remove that
registration to get the native-only behaviour.

Inside a `NativeComponent`: `$this->param('id')`, `$this->data('key', 'default')`, `$this->navigate('/item/42')`,
`$this->back()`, `$this->replace('/login')`, `$this->exitToWeb('/dashboard')`; chain
`->transition(Transition::SlideFromBottom)` to customize animation. In Blade, `@navigate='/path'` works on any
element (modifiers: `@navigate.back`, `@navigate.replace.fade`, `@navigate.slideFromBottom`). Use single quotes
for a plain route; double quotes hold a PHP expression, so `@navigate="/path"` fails to compile. Build dynamic
routes in PHP: `@navigate="'/item/' . $item->id"`.

Lifecycle hooks: `mount()` (first push only), `onResume()` (returning to the screen), `onBackPressed()`
(Android back button), `unmount()`, and `updated{Property}()` when a bound property changes. Mark a component
`#[Lazy]` to paint a placeholder instantly while a slow `mount()` runs in the background.

### A complete screen

`php artisan native:make Notes` writes the class and `resources/views/native/notes.blade.php`. Public properties
are view variables and `native:model` targets. `render()` runs again after every handler call.

```php
namespace App\NativeComponents;

use App\Models\Note;
use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;

class Notes extends NativeComponent
{
    public string $title = '';

// @submit passes the field's text as the last argument, so a stale bound value can't lose input.
    public function save(?string $submitted = null): void
    {
        $title = trim($submitted ?? $this->title);
        if ($title === '') {
            return;
        }
        Note::create(['title' => $title]);
        $this->title = '';   // clears the bound field on the next render
    }

    public function pin(int $id, bool $checked): void { Note::whereKey($id)->update(['pinned' => $checked]); }

    public function remove(int $id): void { Note::whereKey($id)->delete(); }

    public function render(): View
    {
        return view('native.notes', ['notes' => Note::orderBy('id')->get()]);
    }
}
```

```blade
<native:top-bar title="Notes" />

<native:column class="w-full h-full bg-theme-background">
    <native:row class="w-full items-center gap-2 px-4 py-2">
        <native:outlined-text-input class="flex-1" placeholder="New note" ref="title-input"
            native:model.debounce.300ms="title" @submit="save" />
        <native:button @press="save">Add</native:button>
    </native:row>

<native:list class="w-full flex-1" separator>
        @foreach ($notes as $note)
            <native:list-item native:key="note-{{ $note->id }}"
                :headline="$note->title"
                :leadingCheckbox="$note->pinned" on-leading-change="pin({{ $note->id }})"
                :trailing-actions="[['method' => 'remove('.$note->id.')', 'label' => 'Delete', 'icon' => 'trash', 'role' => 'destructive']]" />
        @endforeach
    </native:list>
</native:column>
```

Handler values are a method name or `method(arg, ...)`. Arguments are parsed as JSON, so quote strings:
`select('abc')`, `remove({{ $id }})`. The event's own value is appended after your arguments: the text for
`@submit`/`@change` on text inputs, a bool for checkbox and switch changes. Any element takes `@press`, including
rows and columns. For element props (list items, swipe actions, text inputs, buttons) activate the
`nativephp-mobile-ui` skill, which ships with `nativephp/mobile-ui`.

## EDGE Elements

Screens are built from `native:` Blade components (the prefix is optional but preferred for clarity):

```blade
<native:column class="w-full h-full p-4 gap-4 bg-theme-background">
    <native:text class="text-2xl font-bold">Welcome</native:text>
    <native:outlined-text-input native:model="name" placeholder="Your name" />
    <native:button label="Save" @tap="save" />
</native:column>
```

The elements and their renderers come from the `nativephp/mobile-ui` plugin. Core has no renderers of its
own, so even `column` and `text` need it. Install and register it before building any screen:
`composer require nativephp/mobile-ui`, then `php artisan native:plugin:register nativephp/mobile-ui` (this
adds `NativeUIServiceProvider` to `plugins()` in `app/Providers/NativeServiceProvider.php`). Check with
`php artisan native:plugin:list`. If it is installed but not registered, bare tags like `<button>` disappear
from the screen and `<native:*>` tags throw "Unknown native element type", in `Native::test()` too.

There is no `text-input` element. Use `outlined-text-input`, `filled-text-input` or `bare-text-input`.

Tap handlers use `@tap` (`@press` is a permanent alias — same for `@longTap`/`@longPress` and
`@tapDown`/`@pressDown`, `@tapUp`/`@pressUp`; both spellings compile identically).

~40 elements are available — layout (column, row, stack, scroll-view, spacer, pressable), content (text, image,
icon, divider, badge, progress-bar, activity-indicator), forms (button, button-group, outlined-text-input,
filled-text-input, bare-text-input, toggle, checkbox, radio-group, select, slider, chip), chrome (top-bar,
bottom-nav, fab, bottom-bar, side-nav — see Native Chrome below), lists (list, lazy-grid, carousel, refreshable),
overlays (modal, bottom-sheet), and drawing (canvas, shapes). Fetch the component's doc page before using it —
required props are validated at render time.

## Custom Fonts

Drop `.ttf`/`.otf`/`.ttc` files into `resources/fonts/` — the build bundles them into the native project
automatically (newly added files need a rebuild with `native:run`). Reference a font by
its filename without extension: `font="Inter-Bold"` on `native:text`, `native:button`, and the text inputs
(fluent: `->font('Inter-Bold')`).

- **Google Fonts**: `php artisan native:font Inter --weights=400,700` downloads straight into `resources/fonts/`
  (no API key; libre-licensed, safe to bundle). Files come out as `<Family>-<Style>.ttf`, ready to use as
  `font` tokens. Safe to run yourself.
- **App-wide default**: set the `default` alias in the `fonts` block of `config/native-ui.php`
  (`'fonts' => ['default' => 'Inter-Regular']`; `'System'` = platform default) — applies to text, buttons,
  inputs, and navigation chrome. `native:font --default` offers to set this for you. A legacy theme
  `font-family` token is still honored when no `default` alias is set. Per-element `font` and
  `font-serif`/`font-mono` classes still win.
- **Chrome fonts**: layouts take a `$font` property, bars a `->font()`, and per-screen
  `NavBarOptions::make()->font()`.
- **Weight gotcha**: one font file = one weight. Avoid `font-bold` on single-weight custom fonts (Android
  synthesizes a faux bold, iOS ignores it) — bundle the Bold file and reference it directly
  (`font="Inter-Bold"`). `font` only changes the typeface; size/weight still come from `text-*`/`font-*`
  classes.

## Data Binding & Reactivity

- `native:model="property"` two-way binds any input-style element to a public property (the native `wire:model`).
  Modifiers: `.blur`/`.lazy`, `.debounce.300ms`. `updated{Property}()` fires on change.
- On text inputs use `native:model.debounce.300ms` (`.debounce` alone is 300ms). The default live mode syncs every
  keystroke and the re-render echoes the value back into the field, which can drop or reorder characters when
  typing is fast (automation, `adb shell input text`). A button tapped inside the debounce window can read a
  stale property, so also take the text from `@submit`, as `save()` does above.
- `#[Computed]` methods are read as properties (`$this->total`), memoized per frame, invalidated on state change;
  `#[Computed(persist: true)]` survives re-renders until state changes.
- `#[Poll(5000)]` on a method runs it on an interval then re-renders; on a class it just re-renders. In Blade:
  `native:poll="1s"` on an element.
- `#[On(EventClass::class)]` listens for native events (push taps, websocket messages via the Vibe plugin,
  bridge completions); parameters bind by name to event properties; listeners auto-teardown on unmount. Use
  `$this->on(Event::class, $closure)` for dynamic registration. (`#[OnNative]` is the legacy webview/Livewire
  equivalent — do not use it in NativeComponents.)

## Nested Components (Child Components)

Any `NativeComponent` can mount others as children — the unit of reuse for repeated UI (cards, rows, chips).
Classes under `app/NativeComponents` are auto-registered as tags by kebab name (`UserCard` →
`<native:user-card>`); registered element names always win over component tags. Register classes living
elsewhere with `ComponentRegistry::components(['user-card' => UserCard::class])`.

```blade
@foreach ($users as $u)
    <native:user-card :user="$u" level="admin" key="user-{{ $u->id }}" @saved="onSaved" />
@endforeach
```

- **Props down.** Attributes assign to the child's matching public properties (kebab → camelCase; `:prop`
  binds expressions, plain attrs pass strings with scalar coercion). Props are re-assigned on **every** parent
  render, so they stay live as parent state changes.
- **Own state persists.** The child's other public properties survive parent re-renders. Give list children a
  stable domain `key` (`key="task-{{ $task->id }}"`, never the loop index) so state follows identity through
  reorders and removals; unkeyed children fall back to tag + position.
- **Events up.** `$this->emit('saved', ...$args)` in the child bubbles to every ancestor: `@saved="onSaved"`
  on the mounting tag calls that parent method (bound args first, emit args appended), and string-form
  `#[On('saved')]` methods fire on any ancestor — including the screen, even from grandchildren. Class-based
  `#[On(EventClass::class)]` native-event listeners are unrelated and stay screen-scoped.
- **Lifecycle.** `mount()` runs when the child's key first appears, `unmount()` when it disappears. Children
  share the screen's run loop — a class-level `#[Poll]` on a child does not schedule timers (use
  `native:poll` inside the child's Blade instead).
- `@tap`, `native:model`, and `updated{Property}()` inside a child's view dispatch to **that child instance**,
  not the screen. Children nest recursively; child navigation calls (`navigate()`, `back()`) forward to the
  screen.
- Slot content between component tags is not supported (it throws) — pass data through props.

## Native Chrome — Composable Blade Elements (Layouts Optional)

Author chrome directly in the screen's Blade. These elements hoist onto the **real** native chrome root
(NavigationStack/TabView on iOS, Scaffold/NavigationBar on Android) — edge-swipe back, predictive back, large
titles, and Liquid Glass/Material You all come for free, exactly as with a layout:

```blade
@use('App\Icons\Ios')
@use('App\Icons\Android')

<native:top-bar title="Inbox" subtitle="{{ $unread }} unread" display-mode="large">
    <native:top-bar-action id="compose" label="Compose" @tap="compose"
                           :ios-icon="Ios::SquareAndPencil" :android-icon="Android::Edit" />
</native:top-bar>

<native:bottom-nav>
    <native:bottom-nav-item id="home" label="Home" url="/" icon="home" badge="{{ $unread ?: '' }}" />
    <native:bottom-nav-item id="settings" label="Settings" url="/settings"
                            :ios-icon="Ios::Gearshape" :android-icon="Android::Settings" />
</native:bottom-nav>

<native:fab icon="add" @tap="create" />
```

- **Chrome is reactive** — attributes are Blade expressions over screen state, so badges, subtitles, and icon
  swaps re-render like any other element.
- **Platform icons**: `:ios-icon` / `:android-icon` take the typed enums (`:ios` / `:android` shorthand also
  works); a plain `icon` string is the cross-platform fallback. `AndroidOutlined` cases carry their variant
  automatically.
- `<native:fab>` floats above the content (give it `@tap` or `url`; `label` makes it an extended fab).
  `<native:bottom-bar>` pins bottom content (chat inputs, search bars) with keyboard avoidance.
- **`custom` escape hatch**: `<native:top-bar custom …>` keeps the bar in the content tree as an ordinary drawn
  element — only for designs the system bars can't express. It still suppresses a layout's bar for that slot.
- Never hand-roll top bars or bottom navs out of rows and pressables — that forfeits native back gestures,
  safe-area handling, and system materials. Use the chrome elements (or the `custom` variant of them).

**Layouts are optional.** A `NativeLayout` class (attach with `Route::native(...)->layout(...)` or
`Route::nativeGroup(TabsLayout::class, fn () => ...)`, override `navBar()` / `tabBar()` with the `NavBar`,
`NavAction`, `TabBar`, `Tab` fluent builders) still earns its keep when many screens share identical chrome —
one tabs layout for a whole tab section, a stack layout with auto-back for pushed details. An inline chrome
element on a screen **overrides the layout's bar for that slot** (the other slot still comes from the layout),
so per-screen chrome belongs in the screen's Blade, shared chrome in a layout. Builder colors take
`theme()`-fed strings (`->activeColor(theme('primary'))`), never hardcoded hex; bar fonts take config aliases
(`font="mono"` / `->font('mono')`).

Screens with chrome — inline or layout — handle safe areas automatically; never add `safe-area` classes to
them (reserve those for fully chrome-less screens).

## Device APIs

**Core built-ins** (`Native\Mobile\Facades`): `Device`, `Dialog`, `File`, `System` — these ship inside
`nativephp/mobile` in v4. Also `System::isIos()` / `isAndroid()` and Blade directives `@ios` / `@android`.
`Device::thermalState()` returns a `ThermalState` enum (`Normal`/`Warm`/`Hot`/`Critical`); listen for
`ThermalStateChanged` to throttle work when the phone heats up. Android maps by impact, not constant name:
`NONE`→normal, `LIGHT`/`MODERATE`→warm, `SEVERE`→hot, `CRITICAL`/`EMERGENCY`/`SHUTDOWN`→critical (8–9 always
Normal). iOS is 1:1 (`nominal`/`fair`/`serious`/`critical`). `Device::getInfo()` JSON (decode it) also
includes `processorCount`, `activeProcessorCount`, `systemUptime`, and `memTotal` (device RAM in bytes,
alongside existing `memUsed`). `Device::getTimezone()` gives the phone's IANA timezone (`Europe/Lisbon`), or
`null` off-device; Laravel's `app.timezone` (UTC by default) is not the phone's.

**Plugins** (separate Composer packages): browser, camera, microphone, network, share (free); biometrics,
geolocation, scanner, secure-storage (paid); firebase (push notifications, proprietary); vibe
(websockets/Reverb). v4 **conflicts** with the old `mobile-device`/`-dialog`/`-file`/`-system` plugins — remove
them with `php artisan native:plugin:uninstall --core-v4` when upgrading.

Installing a plugin is a **four-step flow — never stop after `composer require`** (an unregistered plugin does
nothing):

```bash
composer require vendor/plugin-name
php artisan vendor:publish --tag=nativephp-plugins-provider   # once, before first registration
php artisan native:plugin:register vendor/plugin-name          # adds it to NativeServiceProvider
php artisan native:plugin:list                                 # verify it shows as registered
```

Then rebuild with `native:run` (see Building and Running). If `native:run` warns "The following
plugins are installed but not registered", the register step was missed.

Async calls dispatch events (`Camera::getPhoto()` → `PhotoTaken`); handle with `#[On(PhotoTaken::class)]` in a
NativeComponent. Sync calls return directly (`SecureStorage::get()`, `Network::status()`).

## When a Capability Is Missing

Native functionality or a UI component that core and `native-ui` don't provide is **not** a reason to drop to
the web view. Escalate in this order:

1. **Existing plugin** — check the plugin marketplace (`https://plugins.nativephp.com`) and the core plugins
   list. If a marketplace-lookup MCP tool is available in your session, use it to search.
2. **Custom plugin** — scaffold with `php artisan native:plugin:create`. Plugins bundle PHP facades/events,
   Swift/Kotlin bridge functions, permissions, native dependencies (Gradle/SPM/CocoaPods), and can ship their
   own native EDGE components — so custom native logic *and* custom native UI both belong in a plugin. See
   [references/plugin-best-practices.md](references/plugin-best-practices.md) and the
   `plugins/creating-plugins` docs.

## Database & Seeding

SQLite is the only database driver (deliberate — no remote DB credentials in a distributable binary; use an API
backend for sync). NativePHP creates the DB in the app container and **runs migrations automatically on every
app start**, as needed.

**Seeding must go through migrations** — there is no `db:seed` on device. Create a dedicated seed migration
(`php artisan make:migration seed_app_settings`) with the inserts in `up()`; migrations give you exactly the
seeding semantics you want (run once per installation, tracked, versioned, reversible). If a Seeder class helps
organize larger datasets, create it — but call it from the migration:

```php
public function up(): void
{
    (new \Database\Seeders\CategorySeeder)->run();
}
```

Test seed migrations for both fresh installs and upgrades of existing user databases — a bad migration on update
can destroy user data.

## Testing

Component tests run in-process — no device or simulator. Scaffold with `php artisan native:make-test Counter`:

```php
Native::test(Counter::class)
    ->assertSee('Count: 0')
    ->tap('Increment')
    ->assertSet('count', 1);
```

`Native::visit('/profile/5')` mounts by route; `Native::fakeBridge()` scripts native responses;
`emitNative(Event::class, [...])` delivers device events in tests.

Check behaviour here, not by building and tapping on a device. A test for the Notes screen above:

```php
use App\Models\Note;
use App\NativeComponents\Notes;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Native\Mobile\Testing\Native;   // not Native\Mobile\Facades\Native

uses(RefreshDatabase::class);

it('adds, pins and removes notes', function () {
    $screen = Native::test(Notes::class)
        ->input('title', 'Ada')          // target: model property, method, expression or ref
        ->tap('Add')                     // tap by ref or by visible text
        ->assertSet('title', '')
        ->assertSee('Ada');              // visible text includes list-item headlines and swipe labels

    $id = Note::first()->id;

$screen->check("pin({$id})")         // fires the callback registered for that expression
        ->assertElement('list_item', fn (array $node) => $node['props']['leading_checked'] === true)
        ->press("remove({$id})")         // swipe action, row press, trailing button...
        ->assertDontSee('Ada');

    Native::test(Notes::class)->input('title-input', 'Bob')->submit('save', 'Bob')->assertSee('Bob');
});
```

Other helpers: `toggle()`, `select()`, `longPress()`, `swipe()`, `pressBack()`, `get('prop')`,
`assertNavigatedTo()`, `assertNavTitle()`, `assertNavAction()`, `assertMissingElement()`, `assertAccessible()`,
`tree()` (the wire tree as an array; node types are snake_case, like `list_item` and `outlined_text_input`) and
`dumpTree()`. `:menu` items are `top_bar_action` nodes too, so check the nav bar with `assertNavAction()` and
`assertMissingNavAction()`, not `assertElement('top_bar_action')`. Run with
`php artisan test`. `native:validate` warns "Could not determine view name" for components that
`return view('native.x')`, so rely on tests instead.

## Building and Running

Run builds from the Laravel root, name the device, and disable prompts. Pick a booted simulator or running
emulator; use the platform the user named, otherwise whichever device is running:

```bash
php artisan native:run ios <SIMULATOR_UDID> --no-tty -n    # xcrun simctl list devices booted
php artisan native:run android <SERIAL> --no-tty -n        # adb devices
```

Run it in the foreground and wait: it returns once the app is installed and launched. Build logs are
`nativephp/ios-build.log` and `nativephp/android-build.log`; the Android log ends with
`=== NativePHP Android Build Completed ===` on success.

| Output | Path |
|---|---|
| iOS simulator app | `nativephp/ios/build/Build/Products/Debug-iphonesimulator/NativePHP-simulator.app` (always this name) |
| Android debug APK | `nativephp/android/app/build/outputs/apk/debug/app-debug.apk` |

Both contain the Laravel app and PHP, so no dev server is needed. To install and launch by hand:

```bash
xcrun simctl install <UDID> <path>.app && xcrun simctl launch <UDID> <APP_ID>
adb -s <SERIAL> install -r -d app-debug.apk && adb -s <SERIAL> shell am start -n <APP_ID>/com.nativephp.mobile.ui.MainActivity
```

Traps:

- The app re-extracts its bundled Laravel copy only when the version number (`NATIVEPHP_APP_VERSION`) or the
  build number (`NATIVEPHP_APP_VERSION_CODE`) differs from the one it extracted last. This is on purpose: the
  pair identifies a build. `DEBUG` re-extracts on every launch. With a fixed version like `1.0.0`, a rebuild
  installed over the old one keeps showing the old screens. Keep `DEBUG` while developing; otherwise bump
  `NATIVEPHP_APP_VERSION_CODE` before each build you install. `native:run` never bumps it (`native:package`
  does). Uninstalling also works, but wipes the app's data.
- `native:run android` gives Gradle 600 seconds. A cold first build, especially next to an iOS build, can run
  over and fail. The bundle is ready by then, so finish with `cd nativephp/android && ./gradlew assembleDebug`
  and install the APK as above.
- On Android the first launch shows "Loading..." for 20 to 30 seconds while it unpacks. It isn't hung.

## Legacy Web-View Apps (Maintenance Only)

Some existing apps still render in the web view (Livewire or Inertia). When maintaining them: the `#nativephp`
JS import (`import { camera, dialog, on, off, Events } from '#nativephp'`) exposes device APIs; clean up JS
listeners with `off()` on unmount; `#[OnNative(...)]` handles events in Livewire components; the `nativephpMobile()`
Vite plugin and `nativephpHotFile()` belong in `vite.config.js`; add the `nativephp-safe-area` body class.
**Do not extend these apps with new webview screens** — build new screens natively and recommend converting the
rest with the `nativephp-webview-to-native` skill.

## Common Pitfalls

- Building a screen in the web view when native UI can do it — always default to EDGE + NativeComponent
- `nativephp/mobile-ui` installed but not in `plugins()`: bare tags like `<button>` silently disappear and
  `<native:*>` tags throw "Unknown native element type". Register it and check `native:plugin:list`
- A misspelled event attribute (`@presss`) is dropped without an error, so the element does nothing
- Using `Native\Mobile\Facades\Native` in tests instead of `Native\Mobile\Testing\Native`
- Inline `style="..."` or styling props on EDGE elements — Tailwind classes only
- Emoji characters as icons in labels/buttons/text — use `native:icon` unless the user explicitly asks for emojis
- Using Livewire patterns (`wire:model`, Livewire's `#[On]`) in NativeComponents — use `native:model` and
  `Native\Mobile\Attributes\On`
- Seeding via `DatabaseSeeder`/`db:seed` — it never runs on device; seed from a migration's `up()` instead
- Missing `NATIVEPHP_APP_ID` in `.env` before `native:install`
- Suggesting iOS commands on Windows/Linux
- Adding `safe-area` classes to screens that already have chrome (a layout or inline chrome elements)
- Hardcoding hex colors or font file tokens in views instead of theme tokens (`bg-theme-surface`) and font
  aliases (`font="headline"`) from `config/native-ui.php`
- Hand-rolling top bars / bottom navs out of rows and pressables instead of the chrome elements
  (`<native:top-bar>`, `<native:bottom-nav>`, `<native:fab>`) or a `NativeLayout`
- Using the loop index as a child component `key` — keys must be stable domain ids or state sticks to the
  wrong child on reorder
- Duplicating the same card/row Blade across screens instead of extracting a nested child component
- Putting slot content between component tags (`<native:user-card>…</native:user-card>`) — it throws; pass
  data via props
- Expecting Vite HMR without passing `--vite` (opt-in since v4)
- Installing a plugin with Composer but never running `native:plugin:register` — the plugin silently does
  nothing and `native:run` warns "installed but not registered"; always register and verify with
  `native:plugin:list`
- Leaving the four v3 plugins (device/dialog/file/system) installed after upgrading — composer will refuse to
  resolve; run `native:plugin:uninstall --core-v4`
- Not fetching v4 docs before implementing — use WebFetch with URLs from
  [references/available-docs.md](references/available-docs.md)

For authoring plugins: [references/plugin-best-practices.md](references/plugin-best-practices.md)

NativePHP Mobile v4

NativePHP Mobile v4 ("SuperNative") builds fully native apps: SwiftUI on iOS, Jetpack Compose on Android, driven entirely by PHP. Each screen is a NativeComponent PHP class (think Livewire, but for native views) rendering EDGE Blade elements. There is no web server and — for native UI screens — no web view.

Native UI First — Non-Negotiable

Every screen you build or scaffold must be native UI: a NativeComponent + EDGE elements, registered with Route::native().

  • Never create new screens as web views, Livewire pages, or Inertia pages. The web view is a legacy escape hatch (native:web-view) for embedding web content inside a native screen — never the foundation of one.
  • If the user asks for a webview-based screen, build it natively with EDGE and briefly explain why (performance, platform look-and-feel, no web-view lifecycle issues). Only fall back if they explicitly insist.
  • When you encounter existing webview/Livewire/Inertia screens in an app, suggest migrating them — the nativephp-webview-to-native skill walks through the conversion.
  • Style exclusively with Tailwind utility classes via class="..." / :class="...". Never inline CSS style="..." or ad-hoc styling props.
  • Prefer theme tokens and font aliases over raw values. Publish config/native-ui.php (vendor:publish --tag=native-ui-config), define the palette in its theme block and semantic font aliases in fonts ('headline' => 'ArchivoNarrow-Bold'), then style with bg-theme-* / text-theme-* / border-theme-* classes and font="headline". The token map is open-ended (add success, outline-variant, … to both blocks and bg-theme-success just works) and theme classes accept opacity modifiers (bg-theme-primary/15 — the tonal-fill idiom). Arbitrary bg-[#…] values are only for genuine data-driven color (category identity colors, imagery) and belong in one PHP home (enum/model), never inline per view.
  • Use native:icon for iconography (SF Symbols on iOS, Material Icons on Android — cross-platform names like home resolve on both). Never use emoji characters in UI text, labels, or buttons unless the user explicitly asks for them. Prefer the typed icon enums (App\Icons\Ios, App\Icons\Android, App\Icons\AndroidOutlined, bound with :ios / :android — they're autocompletable and can't misspell a symbol name. The enums are generated, not shipped: if app/Icons/ doesn't exist yet, run php artisan native-ui:generate-icons first (safe to run yourself; re-run with --refresh-material to update). Import them with @use (compiled views have no namespace), or use fully-qualified cases:
  @use('App\Icons\Ios')
  @use('App\Icons\Android')

  <native:icon :ios="Ios::Gearshape" :android="Android::Settings" :size="28" class="text-theme-primary" />

Documentation

Before implementing any feature, fetch the relevant docs using WebFetch. Find the right URL in references/available-docs.md.

WebFetch("https://nativephp.com/docs/mobile/4/the-basics/routing", "Explain Route::native, navigation methods, and transitions")

Build Commands — Run Them Yourself

Check behaviour with Native::test() first (see Testing). When a change needs a build to take effect or to be checked, run native:run yourself against a booted simulator or running emulator. The details, output paths and traps are under Building and Running below. If no simulator or emulator is available, say so and give the user the command.

php artisan native:run ios <UDID> --no-tty -n    # or android <SERIAL>; build, install, launch, then exit
php artisan native:run ios --watch               # build, deploy, hot reload: never exits
php artisan native:watch                         # hot reload only: never exits
php artisan native:jump                          # device dev loop via the Jump app (QR code)
./native run                                     # shortcut wrapper installed by native:install

Ask first, or leave to the user: --watch and native:watch never exit, so don't block on them (start one in the background only when the user wants hot reload). Release builds, signing, native:package and store uploads are the user's call.

For a build-and-verify loop with machine-readable output (native:run ... --json, native:devices, native:status, native:screenshot, native:tail, native:open-url), follow the nativephp-agent-loop skill.

The Vite dev server is opt-in in v4: add --vite to native:run/native:watch only when the app uses JS/CSS HMR (web-view assets). Native UI screens hot-reload without Vite. npm run build -- --mode=ios|android is only needed for apps that still ship web-view assets.

Getting Started

New app from the starter kit:

laravel new my-app --using=nativephp/mobile-starter --no-node

The starter already has nativephp/mobile-ui installed and registered in app/Providers/NativeServiceProvider.php, a routes/mobile.php, an example app/NativeComponents/Home.php with tests/Feature/NativeHomeTest.php, Pest, and it has run native:install for you. When you replace the Home screen, delete or rewrite that test (it asserts the welcome text).

Existing Laravel app:

composer require nativephp/mobile nativephp/mobile-ui
php artisan vendor:publish --tag=nativephp-plugins-provider   # creates app/Providers/NativeServiceProvider.php
php artisan native:plugin:register nativephp/mobile-ui
php artisan native:plugin:list                                # must show mobile-ui as registered
php artisan native:install both                               # after setting the .env keys below

Then create routes/mobile.php (it is loaded automatically). mobile-ui is not optional: core has no renderers, so every element, even column and text, comes from it.

Set env vars before php artisan native:install:

APP_NAME="My App"                     # the display name on both platforms
NATIVEPHP_APP_ID=com.yourcompany.yourapp
NATIVEPHP_APP_VERSION="DEBUG"         # the version number, e.g. 1.2.0
NATIVEPHP_APP_VERSION_CODE="1"        # the build number
# Optional for iOS:
NATIVEPHP_DEVELOPMENT_TEAM=XXXXXXXXXX

The name, app ID and version are applied again on every build, so they can be changed later. Keep NATIVEPHP_APP_VERSION=DEBUG while developing (see Building and Running below).

OS support: macOS builds iOS + Android; Windows/Linux build Android only; WSL unsupported.

SuperNative Screens

Scaffold with php artisan native:make Counter (remove with native:rm). Register screens in routes/mobile.php. The package loads it automatically, but only in native contexts (on device, under Jump, in native:* commands and in tests), so a web deploy of the same app never exposes them:

Route::native('/', Home::class);
Route::native('/item/{id}', ItemDetail::class);

If the app already loads routes/mobile.php itself, the package leaves it alone and it isn't gated. Remove that registration to get the native-only behaviour.

Inside a NativeComponent: $this->param('id'), $this->data('key', 'default'), $this->navigate('/item/42'), $this->back(), $this->replace('/login'), $this->exitToWeb('/dashboard'); chain ->transition(Transition::SlideFromBottom) to customize animation. In Blade, @navigate='/path' works on any element (modifiers: @navigate.back, @navigate.replace.fade, @navigate.slideFromBottom). Use single quotes for a plain route; double quotes hold a PHP expression, so @navigate="/path" fails to compile. Build dynamic routes in PHP: @navigate="'/item/' . $item->id".

Lifecycle hooks: mount() (first push only), onResume() (returning to the screen), onBackPressed() (Android back button), unmount(), and updated{Property}() when a bound property changes. Mark a component #[Lazy] to paint a placeholder instantly while a slow mount() runs in the background.

A complete screen

php artisan native:make Notes writes the class and resources/views/native/notes.blade.php. Public properties are view variables and native:model targets. render() runs again after every handler call.

namespace App\NativeComponents;

use App\Models\Note;
use Illuminate\View\View;
use Native\Mobile\Edge\NativeComponent;

class Notes extends NativeComponent
{
    public string $title = '';

// @submit passes the field's text as the last argument, so a stale bound value can't lose input.
    public function save(?string $submitted = null): void
    {
        $title = trim($submitted ?? $this->title);
        if ($title === '') {
            return;
        }
        Note::create(['title' => $title]);
        $this->title = '';   // clears the bound field on the next render
    }

    public function pin(int $id, bool $checked): void { Note::whereKey($id)->update(['pinned' => $checked]); }

    public function remove(int $id): void { Note::whereKey($id)->delete(); }

    public function render(): View
    {
        return view('native.notes', ['notes' => Note::orderBy('id')->get()]);
    }
}
<native:top-bar title="Notes" />

<native:column class="w-full h-full bg-theme-background">
    <native:row class="w-full items-center gap-2 px-4 py-2">
        <native:outlined-text-input class="flex-1" placeholder="New note" ref="title-input"
            native:model.debounce.300ms="title" @submit="save" />
        <native:button @press="save">Add</native:button>
    </native:row>

<native:list class="w-full flex-1" separator>
        @foreach ($notes as $note)
            <native:list-item native:key="note-{{ $note->id }}"
                :headline="$note->title"
                :leadingCheckbox="$note->pinned" on-leading-change="pin({{ $note->id }})"
                :trailing-actions="[['method' => 'remove('.$note->id.')', 'label' => 'Delete', 'icon' => 'trash', 'role' => 'destructive']]" />
        @endforeach
    </native:list>
</native:column>

Handler values are a method name or method(arg, ...). Arguments are parsed as JSON, so quote strings: select('abc'), remove({{ $id }}). The event's own value is appended after your arguments: the text for @submit/@change on text inputs, a bool for checkbox and switch changes. Any element takes @press, including rows and columns. For element props (list items, swipe actions, text inputs, buttons) activate the nativephp-mobile-ui skill, which ships with nativephp/mobile-ui.

EDGE Elements

Screens are built from native: Blade components (the prefix is optional but preferred for clarity):

<native:column class="w-full h-full p-4 gap-4 bg-theme-background">
    <native:text class="text-2xl font-bold">Welcome</native:text>
    <native:outlined-text-input native:model="name" placeholder="Your name" />
    <native:button label="Save" @tap="save" />
</native:column>

The elements and their renderers come from the nativephp/mobile-ui plugin. Core has no renderers of its own, so even column and text need it. Install and register it before building any screen: composer require nativephp/mobile-ui, then php artisan native:plugin:register nativephp/mobile-ui (this adds NativeUIServiceProvider to plugins() in app/Providers/NativeServiceProvider.php). Check with php artisan native:plugin:list. If it is installed but not registered, bare tags like <button> disappear from the screen and <native:*> tags throw "Unknown native element type", in Native::test() too.

There is no text-input element. Use outlined-text-input, filled-text-input or bare-text-input.

Tap handlers use @tap (@press is a permanent alias — same for @longTap/@longPress and @tapDown/@pressDown, @tapUp/@pressUp; both spellings compile identically).

~40 elements are available — layout (column, row, stack, scroll-view, spacer, pressable), content (text, image, icon, divider, badge, progress-bar, activity-indicator), forms (button, button-group, outlined-text-input, filled-text-input, bare-text-input, toggle, checkbox, radio-group, select, slider, chip), chrome (top-bar, bottom-nav, fab, bottom-bar, side-nav — see Native Chrome below), lists (list, lazy-grid, carousel, refreshable), overlays (modal, bottom-sheet), and drawing (canvas, shapes). Fetch the component's doc page before using it — required props are validated at render time.

Custom Fonts

Drop .ttf/.otf/.ttc files into resources/fonts/ — the build bundles them into the native project automatically (newly added files need a rebuild with native:run). Reference a font by its filename without extension: font="Inter-Bold" on native:text, native:button, and the text inputs (fluent: ->font('Inter-Bold')).

  • Google Fonts: php artisan native:font Inter --weights=400,700 downloads straight into resources/fonts/ (no API key; libre-licensed, safe to bundle). Files come out as <Family>-<Style>.ttf, ready to use as font tokens. Safe to run yourself.
  • App-wide default: set the default alias in the fonts block of config/native-ui.php ('fonts' => ['default' => 'Inter-Regular']; 'System' = platform default) — applies to text, buttons, inputs, and navigation chrome. native:font --default offers to set this for you. A legacy theme font-family token is still honored when no default alias is set. Per-element font and font-serif/font-mono classes still win.
  • Chrome fonts: layouts take a $font property, bars a ->font(), and per-screen NavBarOptions::make()->font().
  • Weight gotcha: one font file = one weight. Avoid font-bold on single-weight custom fonts (Android synthesizes a faux bold, iOS ignores it) — bundle the Bold file and reference it directly (font="Inter-Bold"). font only changes the typeface; size/weight still come from text-*/font-* classes.

Data Binding & Reactivity

  • native:model="property" two-way binds any input-style element to a public property (the native wire:model). Modifiers: .blur/.lazy, .debounce.300ms. updated{Property}() fires on change.
  • On text inputs use native:model.debounce.300ms (.debounce alone is 300ms). The default live mode syncs every keystroke and the re-render echoes the value back into the field, which can drop or reorder characters when typing is fast (automation, adb shell input text). A button tapped inside the debounce window can read a stale property, so also take the text from @submit, as save() does above.
  • #[Computed] methods are read as properties ($this->total), memoized per frame, invalidated on state change; #[Computed(persist: true)] survives re-renders until state changes.
  • #[Poll(5000)] on a method runs it on an interval then re-renders; on a class it just re-renders. In Blade: native:poll="1s" on an element.
  • #[On(EventClass::class)] listens for native events (push taps, websocket messages via the Vibe plugin, bridge completions); parameters bind by name to event properties; listeners auto-teardown on unmount. Use $this->on(Event::class, $closure) for dynamic registration. (#[OnNative] is the legacy webview/Livewire equivalent — do not use it in NativeComponents.)

Nested Components (Child Components)

Any NativeComponent can mount others as children — the unit of reuse for repeated UI (cards, rows, chips). Classes under app/NativeComponents are auto-registered as tags by kebab name (UserCard → <native:user-card>); registered element names always win over component tags. Register classes living elsewhere with ComponentRegistry::components(['user-card' => UserCard::class]).

@foreach ($users as $u)
    <native:user-card :user="$u" level="admin" key="user-{{ $u->id }}" @saved="onSaved" />
@endforeach
  • Props down. Attributes assign to the child's matching public properties (kebab → camelCase; :prop binds expressions, plain attrs pass strings with scalar coercion). Props are re-assigned on every parent render, so they stay live as parent state changes.
  • Own state persists. The child's other public properties survive parent re-renders. Give list children a stable domain key (key="task-{{ $task->id }}", never the loop index) so state follows identity through reorders and removals; unkeyed children fall back to tag + position.
  • Events up. $this->emit('saved', ...$args) in the child bubbles to every ancestor: @saved="onSaved" on the mounting tag calls that parent method (bound args first, emit args appended), and string-form #[On('saved')] methods fire on any ancestor — including the screen, even from grandchildren. Class-based #[On(EventClass::class)] native-event listeners are unrelated and stay screen-scoped.
  • Lifecycle. mount() runs when the child's key first appears, unmount() when it disappears. Children share the screen's run loop — a class-level #[Poll] on a child does not schedule timers (use native:poll inside the child's Blade instead).
  • @tap, native:model, and updated{Property}() inside a child's view dispatch to that child instance, not the screen. Children nest recursively; child navigation calls (navigate(), back()) forward to the screen.
  • Slot content between component tags is not supported (it throws) — pass data through props.

Native Chrome — Composable Blade Elements (Layouts Optional)

Author chrome directly in the screen's Blade. These elements hoist onto the real native chrome root (NavigationStack/TabView on iOS, Scaffold/NavigationBar on Android) — edge-swipe back, predictive back, large titles, and Liquid Glass/Material You all come for free, exactly as with a layout:

@use('App\Icons\Ios')
@use('App\Icons\Android')

<native:top-bar title="Inbox" subtitle="{{ $unread }} unread" display-mode="large">
    <native:top-bar-action id="compose" label="Compose" @tap="compose"
                           :ios-icon="Ios::SquareAndPencil" :android-icon="Android::Edit" />
</native:top-bar>

<native:bottom-nav>
    <native:bottom-nav-item id="home" label="Home" url="/" icon="home" badge="{{ $unread ?: '' }}" />
    <native:bottom-nav-item id="settings" label="Settings" url="/settings"
                            :ios-icon="Ios::Gearshape" :android-icon="Android::Settings" />
</native:bottom-nav>

<native:fab icon="add" @tap="create" />
  • Chrome is reactive — attributes are Blade expressions over screen state, so badges, subtitles, and icon swaps re-render like any other element.
  • Platform icons: :ios-icon / :android-icon take the typed enums (:ios / :android shorthand also works); a plain icon string is the cross-platform fallback. AndroidOutlined cases carry their variant automatically.
  • <native:fab> floats above the content (give it @tap or url; label makes it an extended fab). <native:bottom-bar> pins bottom content (chat inputs, search bars) with keyboard avoidance.
  • custom escape hatch: <native:top-bar custom …> keeps the bar in the content tree as an ordinary drawn element — only for designs the system bars can't express. It still suppresses a layout's bar for that slot.
  • Never hand-roll top bars or bottom navs out of rows and pressables — that forfeits native back gestures, safe-area handling, and system materials. Use the chrome elements (or the custom variant of them).

Layouts are optional. A NativeLayout class (attach with Route::native(...)->layout(...) or Route::nativeGroup(TabsLayout::class, fn () => ...), override navBar() / tabBar() with the NavBar, NavAction, TabBar, Tab fluent builders) still earns its keep when many screens share identical chrome — one tabs layout for a whole tab section, a stack layout with auto-back for pushed details. An inline chrome element on a screen overrides the layout's bar for that slot (the other slot still comes from the layout), so per-screen chrome belongs in the screen's Blade, shared chrome in a layout. Builder colors take theme()-fed strings (->activeColor(theme('primary'))), never hardcoded hex; bar fonts take config aliases (font="mono" / ->font('mono')).

Screens with chrome — inline or layout — handle safe areas automatically; never add safe-area classes to them (reserve those for fully chrome-less screens).

Device APIs

Core built-ins (Native\Mobile\Facades): Device, Dialog, File, System — these ship inside nativephp/mobile in v4. Also System::isIos() / isAndroid() and Blade directives @ios / @android. Device::thermalState() returns a ThermalState enum (Normal/Warm/Hot/Critical); listen for ThermalStateChanged to throttle work when the phone heats up. Android maps by impact, not constant name: NONE→normal, LIGHT/MODERATE→warm, SEVERE→hot, CRITICAL/EMERGENCY/SHUTDOWN→critical (8–9 always Normal). iOS is 1:1 (nominal/fair/serious/critical). Device::getInfo() JSON (decode it) also includes processorCount, activeProcessorCount, systemUptime, and memTotal (device RAM in bytes, alongside existing memUsed). Device::getTimezone() gives the phone's IANA timezone (Europe/Lisbon), or null off-device; Laravel's app.timezone (UTC by default) is not the phone's.

Plugins (separate Composer packages): browser, camera, microphone, network, share (free); biometrics, geolocation, scanner, secure-storage (paid); firebase (push notifications, proprietary); vibe (websockets/Reverb). v4 conflicts with the old mobile-device/-dialog/-file/-system plugins — remove them with php artisan native:plugin:uninstall --core-v4 when upgrading.

Installing a plugin is a four-step flow — never stop after composer require (an unregistered plugin does nothing):

composer require vendor/plugin-name
php artisan vendor:publish --tag=nativephp-plugins-provider   # once, before first registration
php artisan native:plugin:register vendor/plugin-name          # adds it to NativeServiceProvider
php artisan native:plugin:list                                 # verify it shows as registered

Then rebuild with native:run (see Building and Running). If native:run warns "The following plugins are installed but not registered", the register step was missed.

Async calls dispatch events (Camera::getPhoto() → PhotoTaken); handle with #[On(PhotoTaken::class)] in a NativeComponent. Sync calls return directly (SecureStorage::get(), Network::status()).

When a Capability Is Missing

Native functionality or a UI component that core and native-ui don't provide is not a reason to drop to the web view. Escalate in this order:

  1. Existing plugin — check the plugin marketplace (https://plugins.nativephp.com) and the core plugins list. If a marketplace-lookup MCP tool is available in your session, use it to search.
  2. Custom plugin — scaffold with php artisan native:plugin:create. Plugins bundle PHP facades/events, Swift/Kotlin bridge functions, permissions, native dependencies (Gradle/SPM/CocoaPods), and can ship their own native EDGE components — so custom native logic and custom native UI both belong in a plugin. See references/plugin-best-practices.md and the plugins/creating-plugins docs.

Database & Seeding

SQLite is the only database driver (deliberate — no remote DB credentials in a distributable binary; use an API backend for sync). NativePHP creates the DB in the app container and runs migrations automatically on every app start, as needed.

Seeding must go through migrations — there is no db:seed on device. Create a dedicated seed migration (php artisan make:migration seed_app_settings) with the inserts in up(); migrations give you exactly the seeding semantics you want (run once per installation, tracked, versioned, reversible). If a Seeder class helps organize larger datasets, create it — but call it from the migration:

public function up(): void
{
    (new \Database\Seeders\CategorySeeder)->run();
}

Test seed migrations for both fresh installs and upgrades of existing user databases — a bad migration on update can destroy user data.

Testing

Component tests run in-process — no device or simulator. Scaffold with php artisan native:make-test Counter:

Native::test(Counter::class)
    ->assertSee('Count: 0')
    ->tap('Increment')
    ->assertSet('count', 1);

Native::visit('/profile/5') mounts by route; Native::fakeBridge() scripts native responses; emitNative(Event::class, [...]) delivers device events in tests.

Check behaviour here, not by building and tapping on a device. A test for the Notes screen above:

use App\Models\Note;
use App\NativeComponents\Notes;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Native\Mobile\Testing\Native;   // not Native\Mobile\Facades\Native

uses(RefreshDatabase::class);

it('adds, pins and removes notes', function () {
    $screen = Native::test(Notes::class)
        ->input('title', 'Ada')          // target: model property, method, expression or ref
        ->tap('Add')                     // tap by ref or by visible text
        ->assertSet('title', '')
        ->assertSee('Ada');              // visible text includes list-item headlines and swipe labels

    $id = Note::first()->id;

$screen->check("pin({$id})")         // fires the callback registered for that expression
        ->assertElement('list_item', fn (array $node) => $node['props']['leading_checked'] === true)
        ->press("remove({$id})")         // swipe action, row press, trailing button...
        ->assertDontSee('Ada');

    Native::test(Notes::class)->input('title-input', 'Bob')->submit('save', 'Bob')->assertSee('Bob');
});

Other helpers: toggle(), select(), longPress(), swipe(), pressBack(), get('prop'), assertNavigatedTo(), assertNavTitle(), assertNavAction(), assertMissingElement(), assertAccessible(), tree() (the wire tree as an array; node types are snake_case, like list_item and outlined_text_input) and dumpTree(). :menu items are top_bar_action nodes too, so check the nav bar with assertNavAction() and assertMissingNavAction(), not assertElement('top_bar_action'). Run with php artisan test. native:validate warns "Could not determine view name" for components that return view('native.x'), so rely on tests instead.

Building and Running

Run builds from the Laravel root, name the device, and disable prompts. Pick a booted simulator or running emulator; use the platform the user named, otherwise whichever device is running:

php artisan native:run ios <SIMULATOR_UDID> --no-tty -n    # xcrun simctl list devices booted
php artisan native:run android <SERIAL> --no-tty -n        # adb devices

Run it in the foreground and wait: it returns once the app is installed and launched. Build logs are nativephp/ios-build.log and nativephp/android-build.log; the Android log ends with === NativePHP Android Build Completed === on success.

Output Path
iOS simulator app nativephp/ios/build/Build/Products/Debug-iphonesimulator/NativePHP-simulator.app (always this name)
Android debug APK nativephp/android/app/build/outputs/apk/debug/app-debug.apk

Both contain the Laravel app and PHP, so no dev server is needed. To install and launch by hand:

xcrun simctl install <UDID> <path>.app && xcrun simctl launch <UDID> <APP_ID>
adb -s <SERIAL> install -r -d app-debug.apk && adb -s <SERIAL> shell am start -n <APP_ID>/com.nativephp.mobile.ui.MainActivity

Traps:

  • The app re-extracts its bundled Laravel copy only when the version number (NATIVEPHP_APP_VERSION) or the build number (NATIVEPHP_APP_VERSION_CODE) differs from the one it extracted last. This is on purpose: the pair identifies a build. DEBUG re-extracts on every launch. With a fixed version like 1.0.0, a rebuild installed over the old one keeps showing the old screens. Keep DEBUG while developing; otherwise bump NATIVEPHP_APP_VERSION_CODE before each build you install. native:run never bumps it (native:package does). Uninstalling also works, but wipes the app's data.
  • native:run android gives Gradle 600 seconds. A cold first build, especially next to an iOS build, can run over and fail. The bundle is ready by then, so finish with cd nativephp/android && ./gradlew assembleDebug and install the APK as above.
  • On Android the first launch shows "Loading..." for 20 to 30 seconds while it unpacks. It isn't hung.

Legacy Web-View Apps (Maintenance Only)

Some existing apps still render in the web view (Livewire or Inertia). When maintaining them: the #nativephp JS import (import { camera, dialog, on, off, Events } from '#nativephp') exposes device APIs; clean up JS listeners with off() on unmount; #[OnNative(...)] handles events in Livewire components; the nativephpMobile() Vite plugin and nativephpHotFile() belong in vite.config.js; add the nativephp-safe-area body class. Do not extend these apps with new webview screens — build new screens natively and recommend converting the rest with the nativephp-webview-to-native skill.

Common Pitfalls

  • Building a screen in the web view when native UI can do it — always default to EDGE + NativeComponent
  • nativephp/mobile-ui installed but not in plugins(): bare tags like <button> silently disappear and <native:*> tags throw "Unknown native element type". Register it and check native:plugin:list
  • A misspelled event attribute (@presss) is dropped without an error, so the element does nothing
  • Using Native\Mobile\Facades\Native in tests instead of Native\Mobile\Testing\Native
  • Inline style="..." or styling props on EDGE elements — Tailwind classes only
  • Emoji characters as icons in labels/buttons/text — use native:icon unless the user explicitly asks for emojis
  • Using Livewire patterns (wire:model, Livewire's #[On]) in NativeComponents — use native:model and Native\Mobile\Attributes\On
  • Seeding via DatabaseSeeder/db:seed — it never runs on device; seed from a migration's up() instead
  • Missing NATIVEPHP_APP_ID in .env before native:install
  • Suggesting iOS commands on Windows/Linux
  • Adding safe-area classes to screens that already have chrome (a layout or inline chrome elements)
  • Hardcoding hex colors or font file tokens in views instead of theme tokens (bg-theme-surface) and font aliases (font="headline") from config/native-ui.php
  • Hand-rolling top bars / bottom navs out of rows and pressables instead of the chrome elements (<native:top-bar>, <native:bottom-nav>, <native:fab>) or a NativeLayout
  • Using the loop index as a child component key — keys must be stable domain ids or state sticks to the wrong child on reorder
  • Duplicating the same card/row Blade across screens instead of extracting a nested child component
  • Putting slot content between component tags (<native:user-card>…</native:user-card>) — it throws; pass data via props
  • Expecting Vite HMR without passing --vite (opt-in since v4)
  • Installing a plugin with Composer but never running native:plugin:register — the plugin silently does nothing and native:run warns "installed but not registered"; always register and verify with native:plugin:list
  • Leaving the four v3 plugins (device/dialog/file/system) installed after upgrading — composer will refuse to resolve; run native:plugin:uninstall --core-v4
  • Not fetching v4 docs before implementing — use WebFetch with URLs from references/available-docs.md

For authoring plugins: references/plugin-best-practices.md

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource