bluefeather/eloquent-filemaker
Work with FileMaker databases through the Data API using familiar Laravel Eloquent models and query builder. Supports relationships, portals, containers, field remapping, script running, and automatic authentication and session management across multiple files and credentials.
Server-side prerequisites first. This package is only as good as the layout you point it at. Before writing PHP, confirm: FileMaker Server 17+ (or Claris FileMaker Cloud) with the Data API enabled, a dedicated account whose privilege set has the fmrest extended privilege enabled, a valid TLS certificate (self-signed certs require ssl_verifypeer => false), and a layout that actually contains every field you intend to read or write. Fields not on the layout are invisible to the API — this is the single biggest source of "why is my attribute null".
Install and publish config:
composer require gearbox-solutions/eloquent-filemaker
php artisan vendor:publish --provider="GearboxSolutions\EloquentFileMaker\FileMakerServiceProvider" --tag=config
Point it at your file (.env plus config/filemaker.php):
FM_HOST=fm.example.com
FM_DATABASE=MyApp
FM_USERNAME=web_api
FM_PASSWORD=secret
// config/filemaker.php
'connections' => [
'filemaker' => [
'driver' => 'filemaker',
'host' => env('FM_HOST'),
'database' => env('FM_DATABASE'),
'username' => env('FM_USERNAME'),
'password' => env('FM_PASSWORD'),
'protocol' => 'https',
'port' => 443,
'version' => 'vLatest', // match your server: v1 / v2 / vLatest
],
],
Keep MySQL as your default connection and reference FileMaker by name; do not make FileMaker the app-wide default.
First model — treat the layout name as the table:
namespace App\Models\FileMaker;
use GearboxSolutions\EloquentFileMaker\Model;
class Contact extends Model
{
protected $connection = 'filemaker';
protected $layout = 'Contacts'; // a layout, not a table
protected $primaryKey = 'id'; // a field present on the layout
public $timestamps = false; // FileMaker won't populate these
protected $fillable = ['first_name', 'last_name', 'email', 'status'];
}
First query:
$contacts = Contact::where('status', 'Active')
->orderBy('last_name')
->limit(50)
->get();
First use case to build: a read-only reporting or integration surface over an existing FileMaker solution — a dashboard, a customer lookup, or a webhook-driven sync. Read paths are where this package shines and where you can validate the layout/field assumptions cheaply before touching writes.
Where to look first in the source: the base Model class and the FileMakerQueryBuilder (to see which Eloquent methods are actually translated into the Data API's _query/_sort/_limit/_offset parameters), then the connection class for token/session handling.
Keep FileMaker models at the edge of your app. Wrap them in repositories or dedicated services and return plain arrays/DTOs to controllers. FileMaker's query semantics, pagination, and error model are different enough from SQL that leaking Contact::where(...) into business logic will bite you later. It also makes the eventual "cache/MySQL mirror" decision a one-file change.
Lean on FileMaker find operators instead of SQL constructs — they pass through the where() values:
Contact::where('created', '1/1/2024...12/31/2024')->get(); // range
Contact::where('company', '*smith')->get(); // wildcard
Contact::where('code', '==ABC')->first(); // exact match
Contact::where('status', 'Active')->where('city', 'Boston')->get(); // AND within one request
Paginate explicitly. Don't rely on Model::all(). Use limit()/offset() and always pair with an orderBy() so the found set is stable across requests:
$page = Contact::where('status', 'Active')
->orderBy('last_name')
->orderBy('id')
->limit(100)->offset($page * 100)
->get();
For bulk exports, loop offsets in a queued job rather than trying to stream a whole found set in one HTTP request. paginate() may work via the Data API's foundCount, but explicit limit/offset is the pattern you can reason about.
Writes are recordId-driven. create() returns a model with the FileMaker recordId attached; subsequent save()/delete() calls PATCH/DELETE that specific record:
$contact = Contact::create(['first_name' => 'Jane', 'email' => '[email protected]']);
$contact->email = '[email protected]';
$contact->save(); // PATCH by recordId
$contact->delete(); // DELETE by recordId
Never reconstruct a model from session data or form input and call save() — re-fetch it. The primary key field and the internal recordId are not the same thing, and updates/deletes depend on the latter.
Use portals for related data. Portals come back with the parent record in one round trip, which is exactly what you want given the HTTP cost:
class Contact extends Model
{
public function invoices()
{
return $this->hasMany(Invoice::class, 'contact_id', 'id')
->portal('Invoices'); // portal object name on the layout
}
}
Fetch the parent once, then read $contact->invoices. Verify eager loading (with('invoices')) in your version — if it issues a separate query per parent, you've turned one HTTP call into N.
Let FileMaker run its scripts. If the solution has business logic (auto-enter, validation, related-record creation), invoke it rather than reimplementing it:
$contact = Contact::where('id', $id)->first();
$contact->runScript('Sync to Billing', '{"reason":"web"}');
Prefer post-commit phases for side effects like emails or external calls, and remember the script runs with the API account's privileges, not the end user's.
Multiple files / multiple connections are just more entries in the connections array, with protected $connection = 'filemaker_billing' on the relevant models.
Escape hatch for one-offs: DB::connection('filemaker')->table('Contacts')->where('status', 'Active')->get() gives you the builder without a model when you need a quick probe or a health check.
Container fields (images, documents) come back as URLs that require the session token to fetch. Don't put them straight into <img src>; proxy them through a route in your app that streams the bytes.
Sync pattern for heavy workloads. Schedule a job that pulls changed records (using a modified timestamp range find) into a MySQL table with proper indexes. Do reporting and joins there; keep FileMaker as the system of record. This is the standard escape from "every query is an HTTPS round trip".
Testing: because the Data API is HTTP, Http::fake() (or a Guzzle mock, depending on the version's client) covers unit tests well. Keep a real staging FileMaker file for integration tests — mocked payloads won't catch a field that was removed from a layout.
DB::transaction() gives false confidence: the Data API has no transactional semantics across requests. If you need atomic multi-record work, put it in a FileMaker script and call it once. There is no schema builder — the layout is your schema.null instead of erroring. Add a smoke test per model that asserts expected attributes exist, and re-run it after any FileMaker deployment.find() is not find() in SQL. The Data API's record lookup is by recordId, not your business key. Safest habit: Model::where($primaryKey, $id)->first() for business-key lookups, and use the recordId only for update/delete._query array ANDs fields within one entry and ORs entries together. Chained orWhere() calls can group differently than you expect, and there's no equivalent of omit in SQL. Log the generated payload when a find returns an empty set unexpectedly.whereNull, whereIn, whereHas, subqueries, whereRaw. Validate each one against your FileMaker version. For small result sets, fetching and filtering in PHP is often the pragmatic answer.MM/DD/YYYY by default in the API), not ISO. Declare $casts and be deliberate about the date format on write, or you'll get off-by-a-day bugs and timezone drift. Numeric fields can also come back as strings depending on the field type.$fillable matters more than usual. Every field on the layout comes back in the response, so guard against mass-assignment surprises on both the read and write side, and use $hidden for internal fields.$model->{'Field Name'} for access and keep $fillable keys byte-for-byte identical.401 "no records match the request" (a find miss, not auth), 105 "layout is missing or not accessible", 212 invalid credentials, 952 invalid Data API token, and validation failures returning FileMaker's own message text. Wrap calls and translate these into your app's error model instead of letting raw HTTP exceptions escape.Model::all() on a production layout.toSql() is meaningless here. For debugging, log the outgoing request payload (enable HTTP/Guzzle logging or a middleware) and inspect the _query, _sort, and _offset values. That's where the real bugs live.orderBy() will duplicate and skip records.scopeActive, a whereDateRange helper that emits FM range syntax) rather than repeating find-syntax strings across models. If you need custom transport behavior, swap or extend the connection class in config.How can I help you explore Laravel packages today?