NativePHP for Mobile 4.8.0 is out. Layouts now render their nav and tab bars with native chrome by default, event handlers can assign properties the way the Bottom Sheet and Modal docs have always shown, and child components can opt into #[Memo] so unchanged rows skip a re-render. There's a new SlideToBottom transition for dismissing a screen like a sheet, a set of iOS navigation fixes from Wojt Janowski, and events for when the app goes to the background and comes back.
Native chrome is the default#
Until now, a layout had to override usesNativeChrome() and return true to get the platform's own navigation chrome: NavigationStack and TabView on iOS, NavHost and Scaffold on Android. That is now the default (#678). You get edge-swipe back, predictive back, large titles, and Liquid Glass on iOS 26+, without opting in.
If a layout never overrode the method, its bars switch to the native renderers when you upgrade. Inline <native:top-bar> and <native:bottom-nav> already went native, so this mainly brings layout-supplied bars in line with them.
$hidesNavBar and $hidesTabBar still hide the bar on that screen, but the way it happens has changed. On the old Column path, the bar was dropped from the tree. On the native path, the bar config is kept and a hide_nav_bar or hide_tab_bar prop is added, so the NavigationStack and TabView stay in place and push, pop, and tab switching keep working.
You can still opt out by returning false. That fallback is temporary. The Column path will be removed, and native chrome will be the only option:
use Native\Mobile\Edge\Layouts\NativeLayout; class LegacyLayout extends NativeLayout{ public function usesNativeChrome(): bool { return false; }}
When you upgrade, open a couple of existing screens on a device or simulator, including ones that hide the nav or tab bar, and check that the chrome still looks the way you expect.
Handlers can set properties#
The Bottom Sheet and Modal docs have long shown handlers like @press="$showSheet = true" and @dismiss="$showSheet = false". Those assignments never ran. The parser treated the whole expression as a method name, nothing matched, and the event was dropped with no error. After a swipe-away, $showSheet stayed true, so the next tap that set it to true did nothing and the sheet wouldn't open again.
That syntax now works (#677):
<native:button label="Open" @press="$showSheet = true" /> <native:bottom-sheet :visible="$showSheet" @dismiss="$showSheet = false"> <native:column class="w-full p-4"> <native:text class="text-xl font-bold">Sheet title</native:text> <native:button label="Close" @press="$showSheet = false" /> </native:column></native:bottom-sheet>
You can also flip a bool, or assign a number, null, or a quoted string:
<native:pressable @press="$expanded = !$expanded"> ...</native:pressable> <native:pressable @press="$tab = 'settings'"> ...</native:pressable>
The value has to be a literal. Nothing is evaluated, so $open = foo(), $open = $other, or $open = !$other will not run. Assignments go through ComponentState::set(), so #[Locked] is still refused and the updating / updated hooks still fire. The same syntax works on a child component's event binding, such as @saved="$open = false".
A handler that can't run is no longer silent. With app.debug on, it throws InvalidHandlerException and the error screen names it. In production it's logged and nothing runs. If you had a typo or a broken assignment sitting in a view, you'll see that in debug now.
#[Memo] child components#
A screen re-runs its Blade view on every frame: every tap, keystroke, async result, or native event. Nested children re-rendered with it, which made extracted list rows expensive. Craig Jones (@Snitzle) added an opt-in #[Memo] attribute, plus cheaper per-frame rendering of children even without it (#675).
Put it on a child whose output depends only on its props and state:
use Illuminate\View\View;use Native\Mobile\Attributes\Memo;use Native\Mobile\Edge\NativeComponent; #[Memo]class TaskRow extends NativeComponent{ public array $task = []; public function render(): View { return view('native.task-row'); }}
If nothing the child renders from has changed, it reuses the elements from last time and skips its Blade view. It re-renders when its public properties, its @event bindings, or a nested child's public properties change. Streaming renders and a child's first render are never memoized.
It's opt-in for a reason. Memo only compares props, state, and those bindings. If render() also reads the time, the session, config, or the database, the child will keep showing what it showed last. The same is true of #[Computed] or #[Poll] values that change without a public property changing.
SlideToBottom and the transition argument#
Sander (@sandermj) added Transition::SlideToBottom, the inverse of SlideFromBottom, and an optional transition argument on navigate(), back(), and replace() (#452). You can present a screen as a sheet and dismiss it the same way:
use Native\Mobile\Edge\Transition; $this->navigate('/settings', [], Transition::SlideFromBottom);$this->back(Transition::SlideToBottom);
On iOS, the outgoing screen slides down off the bottom edge, revealing the screen underneath.
A <native:top-bar-action> with a url can take a transition attribute too:
<native:top-bar-action id="settings" icon="settings" label="Settings" url="/settings" transition="slide_from_bottom"/>
Existing calls with no transition argument behave as they did before.
iOS navigation, auth, and lifecycle#
Wojt Janowski landed a group of iOS fixes in this release:
- A pushed screen that PHP republishes during the push is redrawn (#643).
- The stack root is redrawn after
replace()(#642). - A tab sub-route opened as the iOS start URL now shows that tab (#663).
- Nav bar subtitles draw on iOS tab screens (#673).
- Plugin overlays close when their screen navigates away (#670).
- Flex-wrap rows wrap onto new lines on iOS (#630).
PHP now hears when the app goes to the background and when it comes back (#672):
use Native\Mobile\Attributes\On;use Native\Mobile\Events\App\AppBackgrounded;use Native\Mobile\Events\App\AppForegrounded; #[On(AppBackgrounded::class)]public function backgrounded(): void{ // Save a draft. The OS may suspend PHP after this, so don't start slow work.} #[On(AppForegrounded::class)]public function foregrounded(int $secondsAway): void{ // $secondsAway keeps counting while the device sleeps.}
A cold launch does not fire AppForegrounded. You can also listen with Event::listen(), because both events implement BroadcastsGlobally.
Guests who navigate in-app to a Route::native() screen behind auth are sent to login again (#666). Since 4.7.0 they were falling through to start_url because ScreenGuard called redirectTo() without the request.
Upgrading#
composer update nativephp/mobilephp artisan native:install --forcephp artisan native:run
composer update leaves the native iOS and Android projects alone, so the navigation and transition work in this release only reaches your app after native:install --force.
The change to watch is native chrome. If your layouts never overrode usesNativeChrome(), they switch to the native renderers. Check those screens, especially any that set $hidesNavBar or $hidesTabBar. Returning false from usesNativeChrome() opts a layout out for now, but that path is going away.
If you debug with app.debug on, handlers that can't run will now throw InvalidHandlerException instead of failing quietly. That's usually a typo or an assignment that isn't a literal.
#[Memo] is opt-in. You don't have to change anything unless you want it.
PHP binaries move to 4.2.1 (#648). There is also more hardening around the web view bridge, file copy and move destinations, device event authentication, and the native-callback cache (#652, #653, #654, #655, #656, #658).
Thanks#
Thanks to Wojt Janowski, Sander (@sandermj), Craig Jones, Andy Swick, Paul (@prosenthal828), Chris Lemon, Martin Rohrlack, and everyone else in the changelog.
Welcome to the first-time contributors named in the release: Andy Swick (@techenby), @m-develops, Filip Krzyżanowski (@fkrzski), Shruti Balasa (@shrutibalasawebdev), Salvatore Forino (@salvymc), and Craig Jones (@Snitzle).