NativePHP Mobile 4.5: Async Tasks Give You a Second PHP Thread
NativePHP Mobile 4.5.0 adds AsyncTask, which runs PHP on a background thread and fires your callback back on the UI thread so the screen never freezes.
There is a moment in every mobile app where you ask PHP to do something that takes two seconds, and the phone just stops. The spinner you carefully added never paints, taps do nothing, and the user assumes the app crashed. On the web you get away with this because the browser is a separate process and the user has a tab full of other things to look at. On a phone, your PHP runtime and your UI share a thread, and blocking one blocks the other.
NativePHP for Mobile 4.5.0, released September 18, ships AsyncTask to fix exactly this. It moves PHP work onto a separate thread with its own interpreter, then hands the result back to your component on the UI thread, where you can set a property and let the screen re-render.
The shape of it
use Native\Mobile\AsyncTask;
public function generateReport(): void
{
$this->generating = true; // paints immediately, spinner shows
AsyncTask::dispatch(static function () {
return ExpensiveReport::build()->toArray();
})->finished(function (array $report) {
$this->report = $report;
$this->generating = false;
});
}
dispatch() returns right away. Your handler exits, the runloop gets control back, and the screen paints the spinner you just set. Meanwhile the closure is running somewhere else entirely. When it finishes, finished() fires bound to your live component, so $this->report = ... behaves the way it does in any other handler.
That last part is what makes this feel Laravel-ish rather than like a threading library bolted on. You do not marshal anything yourself. The work is isolated; the callback is not.
Why the closure has to be static
This is the rule that will bite you first, so it is worth understanding rather than memorizing.
The work runs on a different PHP thread with its own memory space. It cannot see $this, your component properties, or anything else from the dispatching thread. So the API refuses non-static closures outright:
// Fine, captures nothing from the component
AsyncTask::dispatch(static fn () => Report::build());
// Throws InvalidArgumentException at dispatch time
AsyncTask::dispatch(fn () => Report::build($this->month));
I appreciate that this throws in your handler, synchronously, where a stack trace points at the line you wrote. The alternative design, where a bound closure silently fails to find $this in a background context, would produce bug reports nobody could reproduce.
To get data across, capture serializable values explicitly:
$month = $this->month;
AsyncTask::dispatch(static fn () => Report::build($month));
“Serializable” is a hard boundary, not a suggestion. Resources, PDO handles, and open file pointers throw when you try to carry them over. If you have spent time with pcntl_fork or Swoole coroutines this will feel familiar. If you have not, the mental model is: everything the task needs must survive a round trip through serialization.
Results come back as JSON
Return values travel back over the same channel, encoded as JSON. Scalars and arrays work. Eloquent models, file contents, and anything large do not belong here.
For big payloads, write to disk in the task and return the path:
AsyncTask::dispatch(static function () {
$path = storage_path('app/report-'.now()->timestamp.'.pdf');
Report::build()->save($path);
return $path; // a path, not the bytes
})->finished(fn (string $path) => $this->reportPath = $path);
Failures get their own callback:
AsyncTask::dispatch(static fn () => Http::get($url)->json())
->finished(function (array $data) {
$this->data = $data;
$this->loading = false;
})
->failed(function (\Throwable $e) {
$this->error = $e->getMessage();
$this->loading = false;
});
The exception you catch is not the one that was thrown. Exception objects cannot cross the thread boundary either, so you get an AsyncTaskException stand-in carrying the original message, plus originalClass() if you need to branch on what it was. Plan your error handling around message and class name, not instanceof.
There is no retry. A task runs once. If it should try again, do that yourself in failed(), or reach for a queued job instead.
The scoping rule nobody expects
By default, finished() and failed() only fire if the screen that dispatched the task is still the one on top. Navigate away and the callback is dropped on the floor.
This surprised me until I thought about what the alternative would be. The callback is bound to a live component instance so it can mutate state. Firing it against a screen the user has left means either updating something nobody is looking at, or worse, running a closure against a component that has been torn down.
When the result genuinely matters regardless of where the user went, a background upload feeding a status bar, a sync that refreshes a badge, use shared() instead:
AsyncTask::dispatch(static fn () => Sync::run())
->shared('sync-complete');
That delivers a named event rather than a scoped callback, and any active screen can listen:
use Native\Mobile\Attributes\On;
#[On('sync-complete')]
public function syncComplete($event): void
{
$this->lastSync = $event->result;
}
The payload carries an id, a status of finished or failed, and either result or the failure details. This is the right default pair: scoped when the work belongs to a screen, shared when it belongs to the app.
Task classes and parallel dispatch
Inline closures get ugly fast. For anything reusable, extend the class and put the work in handle():
namespace App\Async;
use Native\Mobile\AsyncTask;
class BuildReport extends AsyncTask
{
public function handle(int $month): array
{
return Report::forMonth($month)->toArray();
}
}
use App\Async\BuildReport;
BuildReport::dispatch($this->month)
->finished(fn (array $report) => $this->report = $report);
Same serialization rules apply to the arguments.
Several tasks can be in flight at once, which is the obvious way to load a dashboard:
public function loadDashboard(): void
{
AsyncTask::dispatch(static fn () => Stats::revenue())
->finished(fn ($r) => $this->revenue = $r);
AsyncTask::dispatch(static fn () => Stats::orders())
->finished(fn ($o) => $this->orders = $o);
}
Each callback fires as its own task completes, with no ordering guarantee. Tasks run on a small pool of background PHP contexts; dispatch more than the pool has slots for and the extras wait. That cap exists because every context is a full PHP interpreter holding its own memory, and phones are not generous with RAM.
Testing
AsyncTask::fake() runs everything inline and synchronously, so callbacks fire during the test with no threads involved:
use Native\Mobile\AsyncTask;
it('loads the report', function () {
AsyncTask::fake();
Native::test(ReportScreen::class)
->tap('generateReport')
->assertSee('Revenue');
});
The fake records dispatches too, with assertDispatched(), assertNotDispatched(), assertDispatchedTimes(), and assertShared('alias'). Anyone who has written Queue::fake() assertions will be at home immediately.
One caveat: running inline means your test never exercises the serialization boundary. A closure that captures something unserializable passes the fake and throws on a device. Worth at least one real-device smoke test on anything that carries interesting data across.
Async tasks are not queued jobs
The docs say this plainly and it deserves repeating. Async tasks start immediately, run concurrently, never touch the database or the queue unless you tell them to, and do not survive the app being killed. Queued jobs are durable and retryable and survive restarts. If you need the work to happen eventually, that is a queue. If you need the screen not to freeze right now, that is an async task.
Also: device APIs that need the UI, camera, dialogs, biometrics, do not belong inside a task. Fetch and compute in the background; drive UI from the callbacks.
The rest of 4.5.0
Async tasks are the headline, but the release carries more. routes/mobile.php now loads automatically in native contexts, and native routes register under their prefixed URI. PHP 8.3 support is restored alongside 8.4, which matters if you had a shared codebase pinned below 8.4. Pending OTA zips now apply on boot, with check and download moving out of core. Non-production builds are marked as testing builds for both app stores. On the styling side, glow-* and blur-* utilities landed in the Tailwind parser, and gesture areas picked up pan-x binding with a @dragEnd callback.
nativephp/mobile-ui 0.5.0 shipped alongside with a full-screen snap pager for feeds, an always-on sheet pane for permanent bottom sheets, and a background layer that sits beneath every screen.
Upgrading is three commands, and the order matters:
composer update nativephp/mobile nativephp/mobile-ui
php artisan native:install
php artisan native:run
native:install has to run after the composer update and before native:run, so the native shell picks up the new packages.
If you have been writing mobile screens that quietly block on a slow API call and hoping users do not notice, this is the release that gives you somewhere else to put that work.
Sources
- NativePHP Mobile 4.5.0, NativePHP blog, September 18, 2026
- Async Tasks, NativePHP Mobile v4 documentation
- Queues, NativePHP Mobile v4 documentation
- NativePHP Mobile 4.4.0, NativePHP blog, September 12, 2026
- NativePHP/mobile-air PR #228 (async tasks with UI completion callbacks)