vitisstudio / nativephp-firestore
Cloud Firestore and Firebase Authentication for NativePHP Mobile, backed by the native Firebase SDKs on iOS and Android.
Package info
github.com/VitisStudio/nativephp-firestore
Type:nativephp-plugin
pkg:composer/vitisstudio/nativephp-firestore
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0|^13.0
- nativephp/mobile: ^3.0|^4.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-17 21:02:59 UTC
README
NativePHP Firestore
Cloud Firestore and Firebase Authentication for NativePHP Mobile v4, backed by the native Firebase SDKs (FirebaseFirestore + FirebaseAuth on iOS via Swift Package Manager, firebase-firestore + firebase-auth on Android via the Firebase BoM).
You get the real SDKs' behaviour on-device: offline persistence, realtime listeners, latency-compensated writes, transactions and security rules that see the signed-in user. The API is PHP:
use VitisStudio\NativeFirestore\Facades\Firestore; use VitisStudio\NativeFirestore\FieldValue; $ref = Firestore::collection('notes')->add([ 'text' => 'Buy milk', 'done' => false, 'createdAt' => FieldValue::serverTimestamp(), ]); $open = Firestore::collection('notes') ->where('done', false) ->orderBy('createdAt', 'desc') ->limit(20) ->get(); foreach ($open as $note) { echo $note->id().': '.$note->get('text'); }
The package is app-agnostic: it ships no Firebase project configuration. Each app supplies its own GoogleService-Info.plist / google-services.json, or explicit options.
- Requirements
- Installation
- Configuration
- Firestore API
- Realtime listeners
- Batches and transactions
- Firebase Auth
- Errors
- Testing with fakes
- JavaScript
- Limitations
Requirements
- NativePHP Mobile
^3.0|^4.0(developed against v4.6), PHP 8.2+, Laravel 11–13. - iOS 15+ deployment target (NativePHP apps target 18.x), Xcode 16.2+ (Firebase iOS SDK 12).
- Android minSdk 26.
- Builds with
php artisan native:run, not Jump. Jump runs a prebuilt shell app that only contains NativePHP's own native code, so a plugin with custom Swift/Kotlin (like this one) isn't in it. Under Jump every call fails with abridge-unavailableerror explaining this. Usenative:runbuilds on a simulator, emulator or device.
Installation
composer require vitisstudio/nativephp-firestore
php artisan native:plugin:register vitisstudio/nativephp-firestore
php artisan vendor:publish --tag=firestore-config # optional
native:plugin:register adds the service provider to your NativeServiceProvider::plugins(). NativePHP requires that step before it compiles a plugin into your build. The next php artisan native:run adds the Firebase packages to the native projects.
Configuration
Firebase is initialised once per app launch, on the first Firestore or Auth call. The native code chooses the configuration in this order:
- An existing FirebaseApp. If your app or another plugin (for example NativePHP's push-notification Firebase plugin) has already configured Firebase, this plugin reuses it.
- Explicit options from
config/firestore.php/.env, whenFIREBASE_PROJECT_IDis set. - Your config file:
GoogleService-Info.plist(iOS) orgoogle-services.json(Android). - Otherwise the call fails with a
not-configurederror and a message saying what to add. The app keeps running.
Option A: config files from the Firebase console
Put GoogleService-Info.plist and/or google-services.json in your project root. If they live elsewhere, point FIREBASE_IOS_CONFIG_PATH / FIREBASE_ANDROID_CONFIG_PATH at them. The plugin's copy_assets build hook copies them into the native projects as NativeFirestore-GoogleService-Info.plist (iOS bundle) and assets/nativephp-firestore/google-services.json (Android). The plugin-specific names can't collide with files another Firebase plugin installs. Android doesn't need the Google Services Gradle plugin, because the JSON is read at runtime.
Option B: explicit options
FIREBASE_PROJECT_ID=my-project FIREBASE_API_KEY=AIza... FIREBASE_SENDER_ID=1234567890 FIREBASE_IOS_APP_ID=1:1234567890:ios:abc123 FIREBASE_ANDROID_APP_ID=1:1234567890:android:abc123 FIREBASE_STORAGE_BUCKET=my-project.appspot.com # optional
FIREBASE_APP_ID works as a fallback for both platform app ids.
Option C: Emulator Suite with a demo project (no Firebase account)
firebase emulators:start --only firestore,auth --project demo-myapp
FIREBASE_PROJECT_ID=demo-myapp FIREBASE_EMULATOR=true
A project id that starts with demo- needs no other options; the native code fills in placeholder app ids and API keys. When the emulator host is left empty, each platform uses the address that reaches your machine: 127.0.0.1 from the iOS simulator and 10.0.2.2 from the Android emulator. On a physical device, set your machine's LAN IP:
FIRESTORE_EMULATOR_HOST=192.168.1.20 FIRESTORE_EMULATOR_PORT=8080 FIREBASE_AUTH_EMULATOR_HOST=192.168.1.20 FIREBASE_AUTH_EMULATOR_PORT=9099
All keys
Config (firestore.*) |
Env | Default |
|---|---|---|
ios.config_path |
FIREBASE_IOS_CONFIG_PATH |
base_path('GoogleService-Info.plist') |
android.config_path |
FIREBASE_ANDROID_CONFIG_PATH |
base_path('google-services.json') |
options.project_id |
FIREBASE_PROJECT_ID |
– |
options.api_key |
FIREBASE_API_KEY |
– |
options.sender_id |
FIREBASE_SENDER_ID |
derived from the app id |
options.storage_bucket |
FIREBASE_STORAGE_BUCKET |
– |
options.ios_app_id |
FIREBASE_IOS_APP_ID (or FIREBASE_APP_ID) |
– |
options.android_app_id |
FIREBASE_ANDROID_APP_ID (or FIREBASE_APP_ID) |
– |
database |
FIRESTORE_DATABASE |
(default) |
emulator.enabled |
FIREBASE_EMULATOR |
false |
emulator.firestore.host / .port |
FIRESTORE_EMULATOR_HOST / _PORT |
platform default / 8080 |
emulator.auth.host / .port |
FIREBASE_AUTH_EMULATOR_HOST / _PORT |
platform default / 9099 |
persistence.enabled |
FIRESTORE_PERSISTENCE |
true |
persistence.cache_size_bytes |
FIRESTORE_CACHE_SIZE_BYTES |
100 MB (-1 = unlimited, minimum 1 MB) |
timeout |
FIRESTORE_TIMEOUT |
15 seconds per blocking call |
wait_for_server |
FIRESTORE_WAIT_FOR_SERVER |
false (see Writes) |
server_timestamps |
FIRESTORE_SERVER_TIMESTAMPS |
none (estimate / previous) |
transaction_max_attempts |
FIRESTORE_TRANSACTION_MAX_ATTEMPTS |
5 |
NativePHP bundles your
.envinto the app. If you usecleanup_env_keysinconfig/nativephp.php, don't strip theFIREBASE_*/FIRESTORE_*keys. Firebase API keys identify the project; they aren't secrets. Security rules are what protect your data.
Configuration is read once per launch. Settings such as persistence, emulator and database are applied before Firestore is first used, so a change only takes effect after an app restart.
Firestore API
References
Firestore::collection('users'); // CollectionReference Firestore::doc('users/ada'); // DocumentReference Firestore::collection('users')->doc('ada'); // same thing Firestore::collection('users')->doc(); // new auto-id (nothing written yet) Firestore::doc('users/ada')->collection('notes'); // subcollection Firestore::collectionGroup('notes'); // every "notes" collection, at any depth
Reading
$snapshot = Firestore::doc('users/ada')->get(); // DocumentSnapshot $snapshot->exists(); $snapshot->id(); $snapshot->data(); $snapshot->get('address.city', 'Unknown'); // dotted paths $snapshot->fromCache(); $snapshot->hasPendingWrites(); use VitisStudio\NativeFirestore\Query\Source; Firestore::doc('users/ada')->get(Source::Cache); // Default | Server | Cache
Queries
Queries are immutable: each builder method returns a new query, so a base query can be reused.
use VitisStudio\NativeFirestore\Query\Filter; use VitisStudio\NativeFirestore\Query\FieldPath; $query = Firestore::collection('tasks') ->where('done', false) // == shorthand ->where('priority', '>=', 2) // == != < <= > >= ->whereIn('owner', ['ada', 'grace']) // in (max 30 values) / not-in (max 10) ->whereArrayContains('tags', 'home') // array-contains ->whereArrayContainsAny('tags', ['a', 'b']) // array-contains-any ->where(Filter::or( Filter::where('status', '==', 'open'), Filter::where('pinned', '==', true), )) ->orderBy('priority', 'desc') ->limit(25); // or ->limitToLast(25) (needs orderBy) $snapshot = $query->get(); // QuerySnapshot (Countable, iterable) $snapshot->documents(); $snapshot->size(); $snapshot->first(); $snapshot->toArray(); $query->first(); // ?DocumentSnapshot $query->count(); // server-side count() aggregation // Cursors take values (one per orderBy) or a DocumentSnapshot $page2 = $query->startAfter($snapshot->documents()[24])->get(); Firestore::collection('tasks')->orderBy('priority')->startAt(2)->endBefore(5)->get(); // Document id Firestore::collection('tasks')->where(FieldPath::documentId(), 'in', ['t1', 't2']);
Firestore's own rules apply: inequality filters need composite indexes once they're combined with other fields (the error message contains the link that creates the index), and not-in can't be combined with !=, in, array-contains-any or OR filters. A query allows one != and one not-in. OR, in and array-contains-any filters expand into at most 30 disjunctions (in and array-contains-any count one per value, so two in lists of 5 and 10 values make 50), and each disjunction may hold one array-contains/array-contains-any, so tags array-contains 'home' OR tags array-contains 'work' is fine but ANDing two of them is not. A not-in list containing null matches nothing. A document cursor needs a value for every field the query is ordered by (explicit orderBy() fields plus any inequality-filter fields), and can't use a document whose ordered field holds a server timestamp that hasn't committed yet. It is built from the snapshot you pass, not by re-reading the document, so the next page starts where that snapshot was even if the document has since changed or been deleted. These mistakes surface as an InvalidArgumentException (or a FirestoreException with code invalid-argument from the device) instead of reaching the SDK.
Writes
$ref = Firestore::doc('users/ada'); $ref->set(['name' => 'Ada', 'age' => 36]); // create / overwrite $ref->set(['age' => 37], merge: true); // deep merge $ref->set($data, merge: ['name', 'address.city']); // merge only these fields $ref->update(['age' => 38, 'address.city' => 'London']); // not-found if missing: thrown when awaited, else a WriteFailed event $ref->delete(); Firestore::collection('users')->add(['name' => 'Grace']); // auto id, returns the DocumentReference
Writes don't wait for the server by default. They're applied to the local cache straight away: listeners fire, reads see them, and they work offline. The SDK syncs them in the background. If the backend later rejects one (for example permission-denied), Firestore rolls the cache back and the plugin dispatches a WriteFailed event. To make writes block until the server acknowledges them, set FIRESTORE_WAIT_FOR_SERVER=true or call Firestore::waitForServer() at runtime. Errors then throw, and offline writes time out after timeout seconds. Firestore::waitForPendingWrites() blocks until everything queued so far has synced.
Values
| PHP | Firestore |
|---|---|
null, bool, int, float, string |
same (10.0 and -0.0 stay doubles and read back as float; ints stay ints) |
| list arrays / associative arrays | array / map ([] is an empty array; use new MapValue, new stdClass or (object) [] for an empty map) |
DTO\MapValue, stdClass |
map (always, even when empty or keyed "0", "1", …) |
DTO\Timestamp, any DateTimeInterface |
timestamp (read back as Timestamp; ->toDateTime() gives Carbon) |
DTO\GeoPoint |
geopoint |
Query\DocumentReference |
reference (read back as a live DocumentReference) |
DTO\Bytes |
bytes |
NAN, INF |
NaN / Infinity |
Maps that look like lists. Snapshots return maps as associative arrays and Firestore arrays as PHP lists. A map that a PHP array can't tell apart from a list (an empty map, or one keyed exactly "0", "1", … "n-1") comes back as an immutable DTO\MapValue instead. It reads like an array ($map['0'], count(), foreach, dotted $snapshot->get('prefs.0')), ->toArray() gives its entries, and writing it back (or passing the snapshot as a query cursor) keeps it a map. Writing [] always stores an empty array; write new MapValue (or new stdClass) for an empty map.
FieldValue::serverTimestamp(), FieldValue::increment(1), FieldValue::arrayUnion(...), FieldValue::arrayRemove(...) and FieldValue::delete() work as field values in writes, but not anywhere beneath an array (even inside a map inside an array), not inside arrayUnion/arrayRemove elements, and not in queries. FieldValue::delete() is only allowed in a merge set() or as a top-level update() value.
The plugin enforces the rest of Firestore's write rules in PHP, throwing InvalidArgumentException before anything reaches the native SDK (where the iOS SDK would raise an uncatchable exception): timestamps must fall between 0001-01-01T00:00:00Z and 9999-12-31T23:59:59Z, arrays cannot sit directly inside arrays, data can nest at most 20 levels, field names cannot be empty or begin and end with __, and update()/merge field paths cannot contain empty segments or ~ * / [ ]. The field name __type is reserved by the plugin's wire format.
Realtime listeners
listen() returns a listener id. Snapshots arrive as events: an initial snapshot first, then one per change.
| Event | Payload (constructor parameters) |
|---|---|
Events\QuerySnapshotChanged |
listenerId, path, documents, changes, fromCache, hasPendingWrites |
Events\DocumentChanged |
listenerId, path, id, exists, data, fromCache, hasPendingWrites |
Events\ListenerFailed |
listenerId, code, message (the listener is already detached) |
Events\WriteFailed |
paths, code, message |
Events\AuthStateChanged |
user (array or null) |
Document data in documents, changes and data is in wire form (typed values and list-like maps still tagged, e.g. {"__type": "map", "value": {}}); call snapshot() on the event, or QuerySnapshot::fromArray() / DocumentSnapshot::fromArray(), for decoded values.
In a NativeComponent, handle them with #[On]. Parameters bind to payload keys by name:
use Native\Mobile\Attributes\On; use VitisStudio\NativeFirestore\DTO\QuerySnapshot; use VitisStudio\NativeFirestore\Events\QuerySnapshotChanged; public ?string $listenerId = null; public array $notes = []; public function mount(): void { $this->listenerId = Firestore::collection('notes')->orderBy('createdAt', 'desc')->listen(); $id = $this->listenerId; $this->registerCleanup(fn () => Firestore::stop($id)); // stop when leaving the screen } #[On(QuerySnapshotChanged::class)] public function notesChanged(string $listenerId, array $documents, array $changes, bool $fromCache): void { if ($listenerId !== $this->listenerId) { return; // events are broadcast; filter by your own listener id } $snapshot = QuerySnapshot::fromArray(compact('documents', 'changes')); $this->notes = $snapshot->toArray(); // DTOs decode Timestamps, references, … foreach ($snapshot->docChanges() as $change) { /* added | modified | removed, oldIndex, newIndex */ } }
The events also implement NativePHP's BroadcastsGlobally, so Event::listen(QuerySnapshotChanged::class, fn ($e) => $e->snapshot()) works anywhere in the app. Payload values are wire-encoded; use ->snapshot() / QuerySnapshot::fromArray() / DocumentSnapshot::fromArray() to get decoded values. In web-view (Livewire) apps the same events arrive as native: events.
listen(includeMetadataChanges: true) also fires when only fromCache / hasPendingWrites change. Stop listeners with Firestore::stop($id) or Firestore::stopAll().
Batches and transactions
use VitisStudio\NativeFirestore\Query\WriteBatch; Firestore::batch(function (WriteBatch $batch) { $batch->set('cities/la', ['name' => 'Los Angeles']) ->update(Firestore::doc('cities/sf'), ['population' => FieldValue::increment(1)]) ->delete('cities/old'); }); // committed atomically (max 500 writes) $batch = Firestore::batch(); // or fluently $batch->set('a/1', ['n' => 1]); $batch->commit();
use VitisStudio\NativeFirestore\Query\Transaction; $newBalance = Firestore::transaction(function (Transaction $tx) { $account = $tx->get('accounts/ada'); // reads first … $balance = $account->get('balance') - 10; $tx->update('accounts/ada', ['balance' => $balance]); // … then writes return $balance; });
Transaction semantics
These are real Firestore transactions, not optimistic emulation. Each one is a native transaction session that PHP drives step by step:
transaction()starts the SDK'srunTransaction. The SDK runs its transaction function on a background thread, and that function waits for commands from PHP.- Each
$tx->get()is executed inside the native transaction, with the SDK's own read tracking. - When your callback returns, its buffered writes go to the native transaction, and the SDK commits them atomically.
- If a document you read changed before the commit, Firestore retries: it runs the transaction function again, the plugin reports
retry, and your callback runs again with fresh reads, up tomaxAttempts(default 5). After the last attempt it throwsFirestoreExceptionwith codeaborted. - If your callback throws, the native transaction is aborted, nothing is written, and your exception is rethrown.
Rules that follow from this, all the same as in the SDKs:
- Every
get()must come before the first write. A read after a write throwsLogicException. - Your callback may run more than once, so keep side effects out of it, apart from the transaction's own writes.
- Transactions need a connection; offline they fail with
unavailable. Use batches or plain writes for offline-friendly updates. $tx->attemptsays which attempt is running (starts at 1).
Firebase Auth
use VitisStudio\NativeFirestore\Facades\FirebaseAuth; $user = FirebaseAuth::createUser('ada@example.com', 'secret123'); // signs in too $user = FirebaseAuth::signInWithEmailAndPassword('ada@example.com', 'secret123'); $user = FirebaseAuth::signInAnonymously(); FirebaseAuth::currentUser(); // ?DTO\User (uid, email, displayName, photoUrl, emailVerified, isAnonymous, …) FirebaseAuth::check(); // bool FirebaseAuth::getIdToken(); // JWT for your own backend; getIdToken(forceRefresh: true) FirebaseAuth::sendPasswordResetEmail('ada@example.com'); FirebaseAuth::signOut(); FirebaseAuth::listen(); // AuthStateChanged now, then on every sign-in / sign-out FirebaseAuth::stopListening();
Auth and Firestore share the same native FirebaseApp, so security rules see request.auth for the signed-in user. Sessions persist across launches; that's the SDK's behaviour.
Errors
Every failure throws a subclass of Exceptions\FirebaseException, with the Firebase code available from errorCode():
FirestoreException:permission-denied,not-found,unavailable,aborted,failed-precondition(often a missing index),deadline-exceeded,invalid-argument,unauthenticated, … Helpers:isPermissionDenied(),isNotFound(),isUnavailable(),isAborted(),isDeadlineExceeded().FirebaseAuthException:invalid-email,invalid-credential,email-already-in-use,weak-password,user-disabled,too-many-requests,network-request-failed, …- Plugin codes:
not-configured(no usable Firebase configuration; the message says what to add) andbridge-unavailable(not running in anative:runbuild that includes the plugin, such as Jump or a plainphp artisan serve).
try { Firestore::doc('admin/settings')->get(); } catch (FirestoreException $e) { if ($e->isPermissionDenied()) { /* … */ } }
The plugin never crashes the app when Firebase isn't configured. Every call returns a not-configured error and the reason is logged ([NativeFirestore] … in the device log, visible with php artisan native:tail).
Testing with fakes
Firestore::fake() and FirebaseAuth::fake() swap in in-memory implementations of the same API, so unit tests run without Firebase, a device or the bridge:
use VitisStudio\NativeFirestore\Facades\Firestore; use VitisStudio\NativeFirestore\Facades\FirebaseAuth; it('completes a task', function () { $firestore = Firestore::fake(); $firestore->seed('tasks/t1', ['title' => 'Write tests', 'done' => false]); app(CompleteTask::class)('t1'); $firestore->assertExists('tasks/t1', ['done' => true]); });
The fake implements Firestore's semantics, not just storage:
- queries: every operator, OR/AND filters, Firestore's cross-type ordering (an empty map
new stdClassand an empty array[]are different values, as in Firestore), implicit ordering by inequality fields, documents missing anorderByfield are excluded, cursors,limitToLast, collection groups,count() - all
FieldValues (serverTimestamp()uses Carbon's test clock) set/ merge /mergeFields/ dottedupdate, and atomic batches that roll back on failure- transactions with real conflict detection: if a document you read changes before commit, the callback re-runs, and after
maxAttemptsyou getaborted - listeners: an initial snapshot plus incremental
docChangesdispatched through Laravel's event dispatcher, soEvent::fake()/Event::assertDispatched()work
Fake helpers: seed(), seedCollection(), document(), documents(), failNext('permission-denied'), rejectNextCommit(), beforeServerCommit(), commits(), emittedEvents(), activeListeners(), assertExists(), assertMissing(), assertCount(), assertListening(), assertNotListening(). Firestore::disableNetwork() makes server reads, count() and transactions fail with unavailable.
Writes in the fake behave like they do with wait-for-server off or on:
- Not waiting (the default): the commit applies straight away, offline too.
rejectNextCommit()makes the backend refuse it afterwards: the fake rolls the documents back and dispatchesWriteFailed, as the SDK does. - Waiting (
Firestore::waitForServer()orFIRESTORE_WAIT_FOR_SERVER=true): the commit needs the server. Offline it throwsFirestoreExceptionunavailableand changes nothing. On a device the call times out instead (deadline-exceeded) and the writes stay queued locally until the device reconnects.rejectNextCommit('permission-denied')makes the next awaited commit throw and change nothing.beforeServerCommit(fn (FakeFirestore $fake, array $paths) => ...)runs before each awaited commit reaches the server. Use it to change documents the way another device would, or throw aFirestoreExceptionto reject the commit.
To drive listener events into a NativeComponent test, replay what the fake emitted:
use Native\Mobile\Testing\Native; $firestore = Firestore::fake(); $screen = Native::test(NotesScreen::class); foreach ($firestore->emittedEvents() as [$event, $payload]) { $screen->emitNative($event, $payload); } $screen->assertSee('Buy milk');
FirebaseAuth::fake() supports addUser(), actingAs(), assertSignedIn(), assertSignedOut(), assertPasswordResetSent(), and uses the SDK error codes (with email-enumeration protection, an unknown email and a wrong password both give invalid-credential).
JavaScript
Web-view apps can call the bridge from JavaScript via resources/js/firestore.js (firestore.* and firebaseAuth.*, one function per bridge function). Values use the same tagged wire format. The native code validates every query and write itself, so a bad one from JavaScript fails with invalid-argument instead of crashing the app. A document cursor ({document, values, inclusive}) must carry the snapshot's value for each field of the full ordering, ending with {__type: 'reference', path} for the document. Query::toWire() in PHP shows the exact shape. The PHP API is the recommended interface.
Example
examples/ contains a small generic notes screen: a realtime list with offline indicator and add / toggle / delete. It comes with an .env template covering all three configuration options, example security rules, route registration and a Pest test that uses the fakes.
Limitations
- Jump isn't supported, because custom native code needs a
native:runbuild (see Requirements). - Blocking calls. PHP waits for each SDK call, up to
timeoutseconds. Reads served from cache return immediately. Server reads,count(), transactions and awaited writes depend on the network. - Settings are per launch. Persistence, emulator and database settings are applied the first time Firestore is used. If another component already started Firestore in the same process, Android reports
failed-precondition. On iOS the SDK raises an exception, so don't touch Firestore natively before this plugin configures it. - iOS SDK preconditions. The iOS SDK reports misuse with Objective-C exceptions, which can't be caught. The plugin validates paths, field names, sentinel placement and query shapes first, and returns those as normal
invalid-argumenterrors. A query shape the plugin doesn't recognise could still abort on iOS where Android would throw. - Not implemented: aggregate
sum()/average(), vector values, bundles / named queries,clearPersistence(), multiple FirebaseApp instances, Auth providers other than email/password and anonymous (Google, Apple, phone…), profile updates and email verification flows. - Listener snapshots arrive as events. A PHP request can't block waiting for the next snapshot; listen from a long-lived NativeComponent (or a global listener) instead.
- Very large query results travel through the bridge as JSON, so page them with
limit()and cursors.
License
MIT
