DEV Community

Cover image for NativePHP v4: Build a Truly Native iOS Screen in Blade, No Xcode Required
Hafiz
Hafiz

Posted on Originally published at hafiz.dev

NativePHP v4: Build a Truly Native iOS Screen in Blade, No Xcode Required

Originally published at hafiz.dev


When I wrote my first NativePHP mobile tutorial, the honest caveat sat in the middle of the post: your Laravel app was running in a webview. A good webview, with real native APIs a bridge call away, but still a browser pretending to be an app.

NativePHP v4 removes the pretence. Blade components now render as real SwiftUI views on iOS and Jetpack Compose views on Android. No webview, no HTML, no JavaScript bridge. The engine is called SuperNative. It debuted at The Vibes, the unofficial extra day of Laracon US, hit Laravel News in mid August, and I have now built and run a screen with it on a real iPhone.

This post is that build, start to finish, including the parts where I hit a wall. And the best bit for anyone who bounced off mobile development before: I never opened Xcode.

What SuperNative actually does

NativePHP ships its own Blade engine. Instead of compiling your components to HTML, it converts them into a compact binary representation of a UI tree and hands that directly to the native shell. PHP and the native layer share memory, so there is no network hop and no bridge round-trip between your component and the screen.

On iOS that tree becomes SwiftUI views. On Android it becomes Jetpack Compose. Your Blade file is the single source of truth for both.

The mental model is Livewire. A screen is a PHP class with public properties and methods. The view is Blade. When a property changes, the screen re-renders. If you have written a Livewire component, you already know how to write a NativePHP screen.

The setup, and the first wall

Two requirements before anything works:

  • PHP 8.4. v4 requires it, and this is the first wall I hit: my machine defaulted to PHP 8.3 and composer require failed with a clear enough constraint error. On a Mac with Homebrew, brew install php gets you 8.4 without touching your default PHP. Point Composer at it explicitly if you keep 8.3 as your daily driver.
  • The starter kit currently scaffolds v3. laravel new my-app --using=nativephp/mobile-starter gave me nativephp/mobile 3.3.7. One extra require fixes it.
laravel new watch-later --using=nativephp/mobile-starter
cd watch-later
composer require "nativephp/mobile:^4.2"
Enter fullscreen mode Exit fullscreen mode

That second command is the actual v4 upgrade. It ran clean for me, which matches the upgrade guide's claim that v4 is additive and needs no application code changes.

Build the screen

The demo is a Watch Later list, the native cousin of the Telegram watch-later bot I built earlier this year. A list of saved videos, tap a row to mark it watched, a running total of queued minutes.

v4 ships a generator that creates both halves of a screen:

php artisan native:make WatchList
Enter fullscreen mode Exit fullscreen mode

That gives you app/NativeComponents/WatchList.php and resources/views/native/watch-list.blade.php, plus the route line to paste:

use App\NativeComponents\WatchList;

Route::native('/', WatchList::class);
Enter fullscreen mode Exit fullscreen mode

Route::native() is the mobile sibling of a Livewire route. Parameters work like web routes, so Route::native('/video/{id}', VideoDetail::class) matches a path segment and the screen reads it with $this->param('id').

The data layer is just Laravel

A full PHP runtime with SQLite runs on the device, so the model and migration are exactly what you would write in any Laravel app:

Schema::create('videos', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('channel');
    $table->unsignedSmallInteger('minutes')->nullable();
    $table->boolean('watched')->default(false);
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

One on-device quirk worth knowing: there is no db:seed on the phone. Migrations run once on app start, so starter data goes into the migration's up() method as plain inserts. It feels wrong for about a minute and then makes complete sense.

The component

namespace App\NativeComponents;

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

class WatchList extends NativeComponent
{
    public function toggleWatched(int $id): void
    {
        $video = Video::findOrFail($id);
        $video->watched = ! $video->watched;
        $video->save();
    }

    public function render(): View
    {
        $videos = Video::orderBy('watched')->latest()->get();

        return view('native.watch-list', [
            'videos' => $videos,
            'queuedMinutes' => $videos->where('watched', false)->sum('minutes'),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Eloquent, on a phone, feeding SwiftUI. That sentence still feels strange to type.

The view

The view uses EDGE elements, Blade tags under the native: namespace that map one-to-one onto native UI. Styling is Tailwind utility classes, parsed by the engine into native modifiers:

<native:top-bar title="Watch Later" />

<native:scroll-view class="w-full h-full bg-zinc-100">
    <native:column class="w-full p-4 gap-3">
        <native:text class="text-sm text-zinc-500">
            {{ $videos->count() }} videos saved, {{ $queuedMinutes }} minutes queued
        </native:text>

        @foreach ($videos as $video)
            <native:pressable key="video-{{ $video->id }}" @tap="toggleWatched({{ $video->id }})">
                <native:row class="w-full items-center gap-3 p-4 bg-white rounded-2xl">
                    <native:icon
                        ios="{{ $video->watched ? 'checkmark.circle.fill' : 'circle' }}"
                        android="{{ $video->watched ? 'check_circle' : 'radio_button_unchecked' }}"
                        size="24"
                        color="{{ $video->watched ? '#16A34A' : '#A1A1AA' }}"
                    />
                    <native:column class="flex-1 gap-1">
                        <native:text class="text-base font-semibold {{ $video->watched ? 'text-zinc-400' : 'text-zinc-900' }}">
                            {{ $video->title }}
                        </native:text>
                        <native:text class="text-sm text-zinc-500">
                            {{ $video->channel }}@if ($video->minutes), {{ $video->minutes }} min @endif
                        </native:text>
                    </native:column>
                </native:row>
            </native:pressable>
        @endforeach
    </native:column>
</native:scroll-view>
Enter fullscreen mode Exit fullscreen mode

Three details that earn a comment:

@tap takes arguments. @tap="toggleWatched({{ $video->id }})" calls the method with the id, Livewire style. @foreach is normal Blade, because it is normal Blade.

<native:top-bar> is real chrome. It hoists onto the actual NavigationStack, so you get native back gestures and large-title behaviour for free. The docs are explicit about never hand-rolling a nav bar out of rows, and they are right.

Icons are per-platform names. There is no shared icon dictionary in core. The ios attribute takes an SF Symbols name, android takes a Material name, and whatever you pass through goes straight to the platform. Get one wrong and the icon silently does not render.

Run it on your phone without Xcode

This is the part of v4 that changes who can use it. Compiling an iOS app still requires a Mac with Xcode. Running one during development no longer does:

php artisan native:jump
Enter fullscreen mode Exit fullscreen mode

Jump starts a dev server and prints a QR code. Scan it with your phone's camera and the free Jump app opens your Laravel app as a native iOS app, rendering your actual Blade over the network. No compilation, no provisioning profiles, no Apple Developer account.

The Watch Later screen rendering as native SwiftUI via Jump

Every element in that screenshot is a SwiftUI view. Tap a row and the checkmark fills, the row title dims, and the queued-minutes counter recalculates, all driven by the PHP component:

Rows toggled watched, the counter updated

Edit the Blade file and the screen hot-reloads on the device. The feedback loop is genuinely faster than my Livewire browser workflow, which I did not expect to write.

You can test screens without a device

The sleeper feature of v4 is the testing harness. php artisan native:make-test WatchList scaffolds a Pest test, and the API reads like Livewire's:

use App\NativeComponents\WatchList;
use Native\Mobile\Testing\Native;

it('toggles a video watched when its row is tapped', function () {
    $video = Video::where('title', 'Laracon US 2026 Keynote')->firstOrFail();

    Native::test(WatchList::class)
        ->tap("video-{$video->id}")
        ->assertSee('66 minutes queued');

    expect($video->refresh()->watched)->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

That runs on your machine in milliseconds, no simulator involved. It can fire taps, long-presses, text input, toggles, swipes and navigation, and assert against the rendered tree. Mobile UI you can put in CI is not something the PHP ecosystem had last year.

The sharp edges

I promised the walls, so here they are.

Form elements live in a plugin, and the plugin is not on Packagist. Core v4 registers layout, text, icons, pressables and navigation chrome. button, text-input, toggle and bottom-sheet come from a separate nativephp/native-ui package distributed through NativePHP's own channels rather than Packagist. My original demo had an add-video form in a bottom sheet, and it died with Unknown native element type: bottom_sheet until I read the service provider source and found the comment explaining the split. For a list-and-tap screen core is plenty. For forms, budget time to sort out plugin access first.

v4 is moving fast. The version number tells the story: 4.0.0, 4.0.1, 4.1.0 and 4.2.0 all shipped within weeks of each other, and 4.2.0 was current when I built this. Nothing broke for me across that churn, but I would not bet a client deadline on the surface staying identical yet.

The PHP 8.4 floor will surprise people. Plenty of Laravel developers are on 8.3 today. The error is clear, the fix is quick, but it is the first thing you will hit.

None of these change the verdict. They are the normal texture of a framework feature that is one month old.

Should you build with it?

If you shipped something on v3, the upgrade is safe and additive. Your webview screens keep working, and you can convert them one at a time. That migration-friendly posture is the same pattern I liked when I compared the desktop side of NativePHP to Electron: NativePHP consistently chooses paths that let you adopt incrementally.

If you are starting fresh: for an internal tool, a companion app for an existing Laravel product, or anything list-and-detail shaped, this is now the fastest route from Laravel skills to a real native app. For a consumer app with heavy custom UI or deep platform integration, native Swift or Kotlin still wins, and NativePHP's own plugin system is the escape hatch when you need one native capability rather than a native rewrite.

FAQ

Do I need a Mac to try this?

Not for development. Jump runs your app on a real device without compiling anything, so any machine that runs Laravel works. You need a Mac with Xcode only when you compile a distributable iOS build for the App Store.

Is NativePHP v4 free?

The core nativephp/mobile package installed from Packagist with no license key, and the Jump app is free. Some UI and capability plugins are distributed separately through NativePHP's own channels, with paid tiers for premium plugins.

Does my existing Livewire knowledge transfer?

Almost embarrassingly well. Public properties are state, methods are actions, @tap and friends bind events to methods, and re-rendering happens when state changes. The view layer is different tags, not a different mental model.

How is this different from React Native or Flutter?

Same destination, different vehicle. React Native bridges JavaScript to native views and Flutter paints its own widgets. NativePHP runs an actual PHP runtime on the device, converts Blade to a native UI tree in shared memory, and renders platform-real SwiftUI and Compose views. You keep Eloquent, migrations and the whole Laravel toolbox.

Can I still use a webview for some screens?

Yes. The webview element remains for legacy screens and edge cases, and v3 apps upgrade without rewriting them. The docs are blunt that new screens should be native, and after building one I see no reason to disagree.

The short version

v3 asked you to accept a webview in exchange for staying in Laravel. v4 stops asking. Blade in, SwiftUI out, Eloquent on the phone, tests in CI, and a QR code instead of Xcode.

The plugin split and the release pace are real costs, and I would wait a quarter before shipping a revenue-critical app on it. But the direction is now unmistakable: the gap between "I know Laravel" and "I shipped a native app" has never been this small.

Top comments (0)