Case Study:

Firebase Admin Dashboard & API Integration

Project Overview

The LockedIn Support Portal is an internal admin tool built for the support staff of a live social mobile app. It provides structured access to customer accounts, content moderation, audit logging, and app metrics — all through a custom-built Laravel 12 + Livewire 3.4 interface. The central engineering challenge was bridging two fundamentally different data stores: a local MySQL database for staff and administrative data, and Firebase (Auth + Firestore) as the primary customer data store, accessible only via REST API.

The Challenge

Firebase as the customer data source. The mobile app stores all user data in Firebase Auth and Firestore. There is no SQL database to query. Every customer profile, journal entry, story, and link lives in Firestore collections.

No gRPC in the PHP environment. The standard Kreait Firebase SDK for PHP relies on gRPC for Firestore access. The Docker PHP 8.3-FPM image used for this project does not have the gRPC extension available. This meant the Firestore integration had to be built from scratch against the raw REST API.

Dual-database architecture. Staff accounts, roles, permissions, audit logs, and moderation reports live in MySQL. Customer data lives entirely in Firebase. Every feature that spans both — like viewing which staff member reviewed which customer — requires resolving identities across two systems.

No prior scaffolding. Before any feature could be built, three service classes, a custom REST client, a DTO layer, and the entire auth/permission stack had to be designed and implemented from scratch. There was no existing Firebase integration in this codebase to build on.

Authentication & Authorization Stack

Staff login is not vanilla Laravel authentication. Every request to an admin route passes through a four-layer middleware chain before reaching a Livewire component:

authverifiedstaffstaff.permission:X

  • auth — standard Laravel session guard
  • verified — Laravel's built-in email verification gate. Staff accounts are provisioned by an admin, not self-registered. Verification ensures the provisioned address is genuinely reachable before access is granted.
  • EnsureStaff — custom middleware that checks is_staff = true and is_active = true on the User model. A deactivated staff account is denied immediately, even with a valid session.
  • CheckStaffPermission — checks the specific permission slug (e.g. customers.manage) against the user's role at runtime. Each route declares the permission it requires.

Permissions are defined in config/staff_permissions.php, which maps role slugs to permission arrays. The middleware reads this at runtime — adding a new role requires a single config entry with no migration, no seeder, and no code change.

Every write action in Livewire components also carries an abort_unless() server-side guard. Route middleware is not re-executed on Livewire POST requests, so this prevents a lower-privileged staff member from invoking restricted methods directly via browser DevTools.

The permission matrix across four roles:

Permission Admin Support Moderator Analyst
dashboard.view
customers.view
customers.manage
customers.suspend
customers.delete
reports.view
reports.manage
audit.view
users.manage
roles.manage
firebase.explore
data.recovery

Key Engineering Decisions

Firebase Service Layer

Firebase access is structured as three classes registered as Laravel singletons:

  • FirebaseService — base class wrapping the Kreait Auth contract, injected via the service container
  • FirebaseAuthService — all Auth operations: list users, get by UID or email, search, disable, enable, revoke sessions, anonymize, and metrics aggregation
  • FirebaseFirestoreService — a custom REST client built from scratch (not a Kreait wrapper). Uses google/auth ServiceAccountCredentials to generate OAuth2 bearer tokens, cached for 50 minutes. All reads and writes are raw Http::withToken() calls against the Firestore REST API, covering: document reads, paginated collection queries, partial document updates via PATCH with updateMask.fieldPaths, aggregation queries via runAggregationQuery, and collection discovery via listCollectionIds.

This separation means Firebase Auth failures are isolated from Firestore failures — each service wraps its errors in a FirebaseConnectionException with graceful UI degradation, and the two systems can be cached and invalidated independently.

FirebaseCustomer DTO

Every customer record in the portal is a FirebaseCustomer data transfer object. The fromFirebaseUser() static factory merges a Kreait UserRecord (from the Auth SDK) with a raw Firestore document array (from the REST client) into a single typed object that the rest of the application works with.

The DTO carries a $categorizedKeys registry — every Firestore field name the portal knows about. Six section extractor methods (getProfileSection(), getActivitySection(), getPrivacySection(), getDeviceSection(), getFlagsSection(), getInternalOverridesSection()) pull from this registry and return label/value row arrays for the view. Any field not registered falls through to getOtherSection(), which surfaces it in a collapsible "Other Firestore Data" panel. This means the portal degrades gracefully when the mobile app adds new Firestore fields — they appear in the catch-all rather than silently disappearing.

Account status is resolved from two sources: Firebase Auth's disabled flag (always available, no Firestore read required) and the Firestore accountStatus field. The DTO exposes both getInferredStatus() (Auth-only, used for list views) and getAccountStatus() (full four-state status: Active / Suspended / Banned / Deleted) for contexts where a Firestore read has already been performed.

Hybrid Caching Strategy

Firestore reads are expensive relative to MySQL. All Firestore responses are cached in Laravel's cache layer:

  • User profile data: 3-minute TTL, invalidated immediately on any mutation (ban, anonymize, password change, profile update)
  • Story collections: 60-second TTL (time-sensitive due to expiry filtering)
  • Paginated collection results: keyed as firebase:list:{collection}:{uid}:page{N}:size{M}, so each page is cached independently
  • Document counts: cached separately with a short TTL to avoid re-running aggregation queries on every page load
Audit Trail

Every write operation logs to AuditLog via a static AuditLog::log(action, target, metadata) factory. The record captures: staff user ID, IP address, action slug, target (e.g., customer Firebase UID), arbitrary JSON metadata, and a result field (success / failure). Failure paths log the exception message as a reason field, so Firebase errors are permanently recorded rather than only shown as transient UI notifications. The audit log is browsable and filterable at /admin/audit-log.

Disaster Recovery

A BackupService appends every AuditLog create and ModerationReport create/update to append-only CSV files in storage/app/backups/. The service uses flock() for concurrent-write safety. Model events trigger this automatically — no call sites need to know about the backup layer.

A recovery UI at /admin/recovery (restricted to admin+) can restore records from a backup CSV. It resolves staff email addresses in the CSV to current user IDs, handles ID conflicts with insertOrIgnore, and provides a dry-run preview before committing.

Notable Engineering Challenges Solved

Firebase Auth Search Without a Search API

Firebase Auth has no native search endpoint. The portal implements a three-tier strategy to minimise latency:

  1. UID fast-path — if the query matches /^[A-Za-z0-9]{28,}$/ (the Firebase UID pattern), call getUser(uid) directly. This is O(1) vs iterating the full user list, and covers the most common support workflow: a UID pasted from a support ticket.
  2. Exact email match — if the query contains @, call getUserByEmail() — a single API call with no iteration.
  3. Client-side iteration fallback — iterate all Auth users (up to 1000, via the Kreait generator), filter by displayName, email, or uid substring match, stop at 25 results.
Firestore Schema Discovery

The mobile app's Firestore collections use non-obvious internal field names. For example, the Threads collection uses threadCaption instead of content and threadMedia instead of imageUrl. There was no schema documentation.

To map these, I temporarily added debug rows in Blade that rendered raw field JSON for each Firestore document. Once the actual field names were confirmed against real data, the debug rows were removed and the service was updated with the correct mappings.

Firebase Token Revocation Ordering Bug

When implementing the "change password" feature, the initial implementation called revokeAllSessions() before updateUser(). This produced a subtle bug: updateUser() resets Firebase's validSince timestamp on the user record, which effectively undoes a prior session revocation.

The fix is to always call changePassword() (via updateUser()) first, then revokeAllSessions(). The revocation timestamp is set after the password change, so it correctly invalidates all existing sessions. This was confirmed by testing sign-in behaviour on the mobile app.

Moderation & Compliance

Staff can file moderation reports on any customer or piece of content directly from the customer profile page. Reports capture: content type (User Profile, Journal, Thread, Story, Community), reason (Spam, Harassment, Inappropriate Content, Misinformation, Impersonation, Other), optional content ID (Firestore document reference), and free-text notes.

A separate moderation queue at /admin/moderation shows all pending reports, filterable by status and reason. Status transitions (pending → reviewed / actioned / dismissed) are gated by reports.manage and audit-logged with reviewer and timestamp. The pending count surfaces as a live badge on the sidebar navigation link.

Reports are stored in MySQL rather than Firestore — the mobile app has no reporting collection in Firestore (verified by querying all 28 root collections). This keeps the portal self-contained and avoids creating Firestore collections the app doesn't know about.

Staff notes on customer profiles are also MySQL-backed. Notes are attributed to author name and role, with relative timestamps and absolute on hover. Add and delete actions are permission-gated and audit-logged.

App Metrics Dashboard

The metrics page (/admin/metrics) provides a read-only snapshot of app health without requiring Firebase Console access. It shows seven metric cards: Total App Users, DAU (last 24h), Active Last 7 Days, MAU (last 30 days), Suspended Accounts, Banned Accounts, and Pending Moderation Reports (live from MySQL).

Two Chart.js visualisations are included: a bar chart of activity windows (24h / 7d / 30d) and a doughnut chart of account status distribution. If Firebase is unavailable, cards display "—" and charts render an offline state — the page never crashes.

DAU/7d/MAU figures use Firebase Auth's lastSignInAt field, which updates on token refresh rather than every app session. A collapsed Data Notes accordion on the page explains this approximation and lists all cache keys and TTLs, so staff understand what they're looking at.

Outcome

The portal is fully operational and used by the LockedIn app's support staff for day-to-day customer operations. Support staff no longer need Firebase Console access for routine tasks — account lookups, bans, password resets, and content review all happen through the portal with a proper permission model and audit trail.

The permission system scales cleanly: adding a new staff role is a single entry in config/staff_permissions.php. The middleware reads this at runtime — no migration, no redeployment, no code change required. The Firestore integration, despite being built against a raw REST API with no SDK support for it, handles all production read/write requirements with caching and pagination. The disaster recovery layer provides a safety net for data that would otherwise be unrecoverable if the MySQL database were lost during a migration or infrastructure change.