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

Firestore Eloquent Laravel Package

roddy/firestore-eloquent

Bring Eloquent-style modeling to Google Cloud Firestore in Laravel. roddy/firestore-eloquent lets you define models and query Firestore collections with familiar Laravel syntax, making document data feel like native Eloquent records.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and wire up credentials

composer require roddy/firestore-eloquent
php artisan vendor:publish --provider="Roddy\FirestoreEloquent\FirestoreEloquentServiceProvider"

Enable the Firestore API in Google Cloud, create a service account with datastore.user (or roles/datastore.owner for dev), and point the SDK at the JSON key. In practice the package reads from either the published config file or the ambient Google credentials chain:

GOOGLE_APPLICATION_CREDENTIALS=/var/www/app/storage/keys/firestore.json
FIREBASE_PROJECT_ID=my-project-id

For local work, run the Firestore emulator and set FIRESTORE_EMULATOR_HOST=localhost:8080 — the underlying google/cloud-firestore client honours it automatically, so you get a free sandbox with no production reads billed.

2. Read the source before the docs

At 16 stars with a single maintainer, the source is the documentation. Read these three files first, in this order, before writing a line of application code:

  • the base model class (src/Model.php or similar) — tells you which Eloquent methods are actually overridden versus inherited-and-broken
  • the query builder — tells you which where operators and aggregates exist
  • the service provider — tells you what's bound in the container and what config keys are real

Ten minutes here saves a day of assuming whereHas() works.

3. First use case: pick something non-relational

The natural first target is a model that would otherwise be a wide, sparse, single-table store: user preferences, feature flags, audit/log events, CMS content blocks, notification records. Create a model extending the package's base class:

use Roddy\FirestoreEloquent\Model; // verify exact FQCN from source

class AuditEvent extends Model
{
    protected $collection = 'audit_events';
    protected $primaryKey = 'id';       // document ID, a string
    public $incrementing = false;
    protected $keyType = 'string';
    protected $fillable = ['actor_id', 'action', 'payload', 'created_at'];
}

Then AuditEvent::create([...]), AuditEvent::find($id), AuditEvent::where('actor_id', $id)->limit(50)->get().

Do not start by porting your users/orders/invoices tables. Those need joins, uniqueness constraints, and transactions — see Gotchas.

Implementation Patterns

Keep a repository boundary around it. Because the abstraction is thin and the package is young, never let AuditEvent::where(...) appear in 40 controllers. Put a single interface in front:

interface AuditRepository { public function recentFor(string $actorId, int $limit = 50): Collection; }

One class implements it with the package, and if you ever need to swap to the raw FirestoreClient (or away entirely), you change one file.

Mix the ORM with the raw client for anything unusual. Bind the underlying client once and reach for it when the Eloquent facade runs out:

$client = app(\Google\Cloud\Firestore\FirestoreClient::class);
$client->collection('audit_events')->document($id)->set($data, ['merge' => true]);

This is the escape hatch for array-contains, array-contains-any, in on 30 values, batched writes, and real transactions. Treat the Eloquent layer as sugar for the 80% CRUD path, not a complete query API.

Subcollections need explicit paths. Firestore paths like users/{uid}/notifications/{nid} rarely map to a flat $collection property. Expect to either configure a path template per model or instantiate the model with a parent scope:

$notifications = Notification::forParent("users/{$user->id}")->orderBy('created_at', 'desc')->get();

Verify from source whether the package supports this at all; if not, use the raw client's ->collection("users/{$uid}/notifications").

Writes: prefer set-with-merge over update. update() fails if the document vanished mid-request; set([...], ['merge' => true]) is idempotent and safer in queued jobs and retries. Mirror that by using the model's save() only where a create-or-replace semantic is acceptable.

Store timestamps as native Firestore timestamps. Eloquent's default created_at/updated_at as strings sorts lexicographically only if you use ISO-8601 with a fixed timezone (UTC always). Prefer the Firestore Timestamp type so orderBy('created_at') works server-side and range queries (where('created_at', '>=', $since)) are index-friendly. Cast on read:

protected $casts = ['created_at' => 'datetime', 'payload' => 'array'];

Nested maps come back as PHP arrays; without the cast you get inconsistent shapes between writes and reads.

Denormalise deliberately. No joins means you either duplicate the fields you display (actor_name alongside actor_id) or do a second find() per row — which turns a 50-row list into 51 billed reads. For list screens, duplicate. For detail screens, join in PHP. Document the choice in a comment, because the next developer will "fix" it.

Cap every list query. The default ->get() on a large collection is a memory and cost incident waiting to happen. Either build a ->limit(100) into your repository methods or use cursor pagination (startAfter($lastDoc)) rather than offset(). Firestore bills for documents skipped by an offset — the 10th page of a 20-per-page list costs 200 reads.

Testing: build your own refresh helper. There is no RefreshDatabase for Firestore. Write a small trait that iterates collections and batch-deletes in the emulator:

protected function clearFirestore(): void
{
    $client = app(FirestoreClient::class);
    foreach ($client->collections() as $collection) {
        foreach ($collection->limit(500)->documents() as $doc) { $doc->reference()->delete(); }
    }
}

Run feature tests against the emulator only; never against a real project.

Gotchas and Tips

No migrations, no schema, no validation. Nothing stops a typo'd field name from creating a permanently divergent document. Compensate with $fillable, a casts array, and a booted() hook or observer that validates on saving. Consider a schema constant on each model listing allowed keys, and reject unknown ones.

No unique constraints. Firestore has no unique index. "One profile per user" must be enforced by using the user ID as the document ID (->document($userId)->set(...)), or by a transaction that reads a lock document and aborts if it exists. Anything else races.

Composite indexes are mandatory and discovered at runtime. where('status','active')->where('actor_id',$id)->orderBy('created_at','desc') will throw FAILED_PRECONDITION with a console URL to create the index. That URL takes minutes to propagate. Build a list of your index definitions in a README and script them with gcloud firestore indexes composite create so a fresh environment isn't a mystery-meat debugging session.

count() may be a full scan. Firestore has native aggregation queries, but a thin Eloquent wrapper often implements count() as ->get()->count() — billing every document. Check the source; if it's the naive version, use the raw aggregation ($query->count()) or maintain a counter document.

save() on a model with no ID. Confirm whether the package generates a Firestore auto-ID, uses Str::uuid(), or throws. If it uses auto-ID, the ID only exists after the write — so any code reading $model->id before save() gets null, and any event listeners fire before the ID is known.

Soft deletes are not free. SoftDeletes adds a deleted_at column and expects every query to filter it — check the builder actually applies the global scope. If not, you'll silently return deleted records. Safer: hard delete plus an archived collection.

Security rules don't apply to the Admin SDK. All traffic from this package bypasses Firestore rules entirely. Never expose a route that lets a client shape the query — validate ownership in your own authorization layer, because the database won't.

Debug by logging the underlying request. When a query returns nothing unexpectedly, it's almost always a type mismatch: Firestore is strictly typed, so where('actor_id', 42) will not match a document stored with "42". Cast at the boundary, keep IDs as strings everywhere, and json_encode the query values into your log before running it.

Pin the version exactly and be ready to fork. MIT-licensed, one maintainer, a 2026 release date, zero dependents and 16 stars means you are the QA team. Use "roddy/firestore-eloquent": "1.2.3" (not ^1.2), keep the repository boundary described above, and check whether the maintainer accepts PRs before you build anything load-bearing on it. The package is a good fit for a side service or an audit-log backend; it is a poor fit for the system of record in a payments product.

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.
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
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console