gcego/data
gcego/data is a lightweight Laravel/PHP package for working with structured data. It offers a fluent API for transforming, validating, and organizing data, helping developers keep application logic clean and maintainable, with sensible defaults and straightforward integration into existing projects.
Requirements and install. PHP 8.3+, Laravel 13 (Illuminate), and gcego/core ^1.0. Nothing else from the GCEGO suite is required — no gcego/components, no gcego/runtime, no blueprints/registry. That matters day-to-day: this is a standalone data engine you can drop into a normal Laravel app without adopting a wider framework.
composer require gcego/data:^1.0
The config file is only offered for publishing in the console, so run the publish command from a console context (not from a web-triggered installer) and review the new cache, transactions, performance and events sections. If you are multi-tenant, bind your own Contracts\TenantResolver over the default NullTenantResolver in a service provider before writing any source that declares TenantScoped.
Where to look first. Read in this order: Gcego\Data\Definitions\* (the typed declaration objects — this is the whole authoring surface), then Source::define(), then Contracts\* (SourceProvider, Operator, FilterValueType, ResolvesRecords, SupportsMutations, SupportsAggregation, ProvidesScopedQueries). If you are coming from a pre-1.0 build, docs/migration.md is not optional — the entire declaration API was replaced and new Source(...), Source::model()/table()/builder()/collection(), Property, Paginate, With, Includable and ->named() are gone.
First use case. A paginated, filterable, searchable listing endpoint backed by an Eloquent model. Define one source class, then hand the request input to Data::query() from the controller and shape the output with a transform. Skeleton (confirm exact argument ordering against the docs — every definition is a ::make() object passed once to Source::define()):
use Gcego\Data\Source;
use Gcego\Data\Definitions\{Name, Model, Columns, Filterables, Sortables, Search, Pagination, Transform};
use Gcego\Data\Filters\Filterable;
use Gcego\Data\Filters\Types\StringType;
use Gcego\Data\Filters\Operators\{Contains, GreaterThanOrEqual};
use Gcego\Data\Filters\Types\Integer;
final class PostSource extends Source
{
public static function define(): Source
{
return Source::define(
Name::make('posts'),
Model::make(Post::class),
Columns::make('id', 'title', 'published_at'),
Filterables::make(
Filterable::make('title', [StringType::make(), Contains::make()]),
Filterable::make('views', [Integer::make(), GreaterThanOrEqual::make()]),
),
Search::make('title'),
Sortables::make('title', 'published_at'),
Pagination::make(20),
Transform::except('internal_notes'),
);
}
}
// controller
$result = Data::query(PostSource::class, $request->all(), $context);
return response()->json([
'data' => $result->toArray(),
'meta' => $result->meta(),
]);
Note Source::define() accepts a provider instance or class name on every Data method, and class names are validated before instantiation — so passing a class string from a controller is a supported, checked path.
Class-based sources, container-resolved. Implement Contracts\SourceProvider on a plain class and let the container build it; inject collaborators (repositories, config, tenant context) through the constructor instead of hard-coding them in a static definition. This is the single biggest ergonomics decision in the package: a class-based source is testable, swappable, and works identically whether you pass the instance or the class name.
Declarative read path. Keep sources declarative — Columns, Constraints, Filterables, Sortables, Search, Pagination, Relations, Includables, Aggregates, Transform. Business logic goes into constraints (mandatory where), field security, before-save callbacks and transforms, not into the controller. The controller's job is: build QueryContext, call Data::query()/aggregate()/scopedQuery(), return DataResult.
Filter contracts first, then UI. Declare each filterable with an explicit value type and one or more operators. Compatibility is validated when the source is defined, and Equal is applied implicitly only when you list no operator at all. Use the sugar helpers — Filterable::text(), Filterable::enum(), Filterable::relation() — and the relation-existence operators (has, doesnt_have) for "has any comments" style facets. Custom domain comparisons belong in Contracts\CustomOperator; complex multi-column filters belong in Filters\CustomFilter + Contracts\FilterHandler. Use Filter::operatorName() (not the enum) when you need the request-facing operator name.
Relationships and aggregates. Declare relations and includables once; clients opt in with include=alias, including relation aggregates (RelationAggregate for count/sum/avg/min/max/exists as subqueries) and sort-by-alias. This is the feature that most reduces hand-written query code: dashboards can be a single query() with three included aggregate aliases instead of N queries. For true reporting, move to Data::aggregate() with an Aggregation and optional TimeBucket (day/week/month/year) — it runs in the database for Eloquent and query-builder sources, with the same authorization, constraints and filter allowlist as query().
Writes are opt-in and transaction-aware. Do not assume Data::create() works on a read-only definition: mutations require a Writable declaration (field allowlist, Laravel validation rules, permitted operations). Wrap multi-step writes in Data::transaction(); the transaction manager handles rollback, isolation levels, savepoints, and retries deadlocks/serialization failures with backoff and jitter. Put invariants in BeforeSave, authorization in policies (create, update, delete, restore, forceDelete), and side effects in listeners for RecordCreated/RecordUpdated/RecordDeleted/RecordRestored — all dispatched after commit, so queueing a search-index update there is safe. MutationResult gives you the affected ids back.
Tenancy and field security as declarations, not middleware. Mark tenant-owned models Contracts\BelongsToTenant and the source TenantScoped; reads scope to the current tenant, creates stamp it, and request input can never set it. Missing tenant fails closed with a 403 — which is the behaviour you want, but see Gotchas. Related records are scoped too via Tenancy\RelationScope, so eager loads, includes, relation aggregates, relation filters and relation search cannot leak across tenants. Reserve QueryContext::forTenant() and acrossTenants() for trusted code (jobs, admin tooling, imports). For column-level rules use Security\FieldAccess with false, a Gate ability, or a closure — dotted paths like FieldAccess::hidden('manager.salary') cover relation fields.
Caching and profiling per call site. Wrap hot sources in Caching\Cached with User, Tenant or Shared isolation. Read DataResult->cache() and DataResult->profile() to confirm hits and query counts. Enable QueryContext::profiled() in local/staging (or performance.profile) to get statement recording, N+1 detection and slow-call logging without leaking bindings into logs.
new Source(...), Source::model/table/builder/collection(), Property, Support\Callback, Paginate, Sort/Sortable, Search, With, Includable, ->named() — all removed. The FilterOperator/ValueType enums and CustomOperator class are replaced by Contracts\Operator and Contracts\FilterValueType, with Contracts\CustomOperator for custom operators. Budget real time for docs/migration.md; this is a rewrite, not a rename.SourceDefinition/SourceCompiler moved to Compilation\. Any code importing from Definition\ will fatal at boot — grep for it first.Integer with contains, or listing an operator the type doesn't support, blows up when the source is built. Keep a test that instantiates every source; it catches declaration errors before a request does.Equal is no longer assumed. If a filter stops matching on exact values after you add Contains, this is why.configurations() now returns a list. Use configuration(Filter::class) and hasConfiguration(Filter::class) rather than indexing by key. validate() likewise returns a list of messages — render them all.Includable or eager-load explicitly.context(). Log context() and make sure your exception handler does too, or you will debug blind.artisan imports bypass it — call Data::invalidate() there. Also remember tenant-scoped sources are keyed per tenant even under CacheIsolation::Shared, and that authorization, field rules and transforms still execute for every caller on a cache hit (a cache hit is not an auth bypass, and it isn't free either).MutationException; input can never set the tenant column. Treat those exceptions as a signal that a field shouldn't have been in the allowlist.Data::aggregate() or scopedQuery() rather than deep-paging.Contracts\ResolvesRecords::find() gained a fifth $aggregates parameter — every custom implementation needs it. Likewise, SourceConfiguration::normalize(): array is declared locally now, not inherited from a components contract.Contracts\SourceProvider, Operator, CustomOperator, FilterHandler, ProvidesScopedQueries, SupportsMutations, SupportsAggregation, TenantResolver, ResolvesRecords. scopedQuery() is the right escape hatch for packages that must aggregate in the database themselves (analytics) while inheriting view authorization, mandatory constraints, tenancy and allowlisted filters.Integration\SourceRenderer, blueprints and registry integration are removed. Rendering is now the application's job; if you relied on BlueprintRole/BlueprintPart or DataManager::sourceOf(), that code must be replaced, not ported.DataManager over the Data facade in package/library code even though the facade is auto-aliased — it keeps tests explicit about tenant and profiling contexts.How can I help you explore Laravel packages today?