Search by

gecole / framework

ecoleplus

Gecole Framework — a small, dependency-free PHP framework: container, router, HTTP kernel, sessions, auth, ORM and query builder, migrations, plain-PHP views, validation, console and logging.

Package info

gitlab.com/gecole/framework

Issues

pkg:composer/gecole/framework

Statistics

Installs: 14

Dependents: 1

Suggesters: 0

Stars: 0

dev-main 2026-10-29 14:13 UTC

This package is auto-updated.

Last update: 2026-10-29 14:16:58 UTC


README

A small, dependency-free PHP framework: container, HTTP kernel, router, sessions, auth, ORM and query builder, migrations, views (plain PHP plus optional Twig/Blade/Latte/Mustache adapters), validation, console and logging.

It has no runtime Composer dependencies — only PHP 8.3+ and a handful of extensions. Everything else (the .NET-style API client, the monitoring tooling, the frontend pipeline of a given application) belongs to the application that uses it.

composer require gecole/framework

What is in the box

AreaClassesNotes
FoundationApplication, Container, Config, Env, ErrorHandler, ServiceProviderauto-wiring container, .env reader, dot-notation config, error/exception rendering
HttpKernel, Request, Response, JsonResponse, RedirectResponse, Pipeline, Middleware, Clientroute resolution, middleware stack, cURL transport (form posts, multipart, retries, TLS options)
RoutingRouter, Route, RouteLoaderparameters, optional segments, where() constraints, groups with prefix/name/middleware, named-route URLs, 404/405, loading web.php/api.php with prefix + middleware + name attributes
Session / AuthSession, Csrf, SessionGuard, UserProvider, Authenticatableflash data, old input, CSRF tokens, session-backed guard with a pluggable user provider
DatabaseConnection, QueryBuilder, Migrations\Migrator, Migrations\Migration, Migrations\Seeder, Schema\SchemaBuilder, Schema\Blueprint, Orm\Model, Orm\ModelQuery, relationsPDO wrapper with a query log, bound-parameter query builder, additive migrations, active-record models with casts, events, eager loading and soft deletes
SupportArr, Str, Json, Collection, Clock, helpers.phpapp(), config(), env(), base_path()/storage_path()/view_path()…, e(), route(), url(), asset(), csrf_field(), session(), auth(), request(), response(), view(), now(), collect()
ValidationValidatorrule strings (required\|string\|min:2\|max:191, url, json, in:, alpha_dash, …)
ConsoleConsole, Command, Input, Outputcommand registry/dispatcher, tables, colour, quiet mode, capturable output
LoggingLoggerdaily/single file channels, levels, in-memory ring buffer, tail() for a log screen
ViewViewFactory, ViewEngine, Engines\PhpEngine, Engines\TwigEngine, Engines\BladeEngine, Engines\LatteEngine, Engines\MustacheEngine, AssetManagerplain-PHP templates by default; engine chosen by file suffix (php, twig, blade.php, latte, mustache), layouts, sections, stacked view paths, custom engines; content-hashed assets from a build manifest with un-hashed fallbacks

Bootstrapping an application

The whole framework is started from one file: an Application over a project root, which loads config/, .env, base bindings, then the service providers listed in config/app.php.

// bootstrap/app.php
require dirname(__DIR__) . '/vendor/autoload.php';

$app = new Gecole\Framework\Foundation\Application(dirname(__DIR__));
$app->bootstrap();

return $app;
// public/index.php
$app = require dirname(__DIR__) . '/bootstrap/app.php';

$request = Gecole\Framework\Http\Request::capture();
$response = $app->handle($request);
$response->send();
$app->terminate($request, $response);
// bin/console
$app = require dirname(__DIR__) . '/bootstrap/app.php';

exit($app->container()->make(Gecole\Framework\Console\Console::class)->run($argv));

Console already knows route:list, serve, migrate, migrate:status, migrate:rollback and db:seed; an application registers its own commands on top of those.

serve runs PHP's built-in server in its own process group and forwards every signal it receives (Ctrl+C, kill, a closed terminal) to it, so stopping the command always stops the server and releases the port — no worker is left behind.

Views and view engines

ViewFactory resolves a dotted view name (pages.about) against one or more view paths and picks the first matching engine by file suffix:

SuffixEnginePackage
.phpbuilt-in plain PHP ($view is the factory)—
.twigTwigtwig/twig
.blade.phpBladeilluminate/view
.latteLattelatte/latte
.mustacheMustachemustache/mustache

The adapter libraries are optional: they are only referenced when a template with that suffix is rendered, and a missing package raises a message saying what to require. An engine can also be named explicitly ($views->render('pages.about.twig'), $views->render('pages.about.blade.php')), and a project can register its own:

$app->views()->extend('tpl', fn ($views) => new MyEngine($views->paths()));
$app->views()->use('tpl');   // default for names without a suffix

app.views.default, app.views.paths and the per-adapter options are read from config/app.php (see the starter for a complete example). Each adapter exposes the framework helpers to its templates (route(), asset(), config(), csrf_field(), …); Blade additionally registers @csrf, @route and @asset, and every engine receives the factory as $view. Mustache is logic-less, so its helpers live under a helpers key: {{#helpers.route}}home{{/helpers.route}} calls route('home').

API routes

A route file is plain PHP that returns function (Router $router, Application $app): void. RouteLoader loads it inside a group, so an API file does not repeat its prefix, middleware or name prefix:

$loader = new Gecole\Framework\Routing\RouteLoader($app->router(), $app);

$loader->load($app->basePath('routes/web.php'), ['middleware' => 'web']);

$loader->load($app->basePath('routes/api.php'), [
    'prefix' => 'api',
    'middleware' => 'api',
    'name' => 'api.',
]);

app.middleware_groups defines the named stacks the kernel expands when it builds the pipeline, so web (session + CSRF) and api (stateless) stay separate. Requests under /api/ that raise an error are rendered as a JSON envelope by ErrorHandler.

More capability, still dependency-free

Recognisable patterns have been folded in without adding dependencies:

  • Events — Gecole\Framework\Events\Dispatcher, wildcard/interface listeners, and the application.booted, request.received and request.handled lifecycle events (event(), event_listen()).
  • Cache & filesystem — app()->cache() / cache() over an Array or File store (app.cache), and app()->files() for project I/O. The repository is injectable: a service can type-hint Gecole\Framework\Cache\Repository and gets the default store. The cache manager is extensible: CacheManager::addDriver() (or app.cache.drivers) registers any backend, and optional packages plug in without the core depending on them (e.g. gecole/cache-redis adds a redis driver).
  • Auth guards — AuthManager resolves named guards; token authenticates a Bearer token against personal_access_tokens (auth('token')), on top of the session web guard.
  • Route model binding — Route::model('user', User::class) + the SubstituteBindings middleware hand the controller a model instead of an id.
  • Validation — database-aware unique and exists rules via a connection resolver. Custom rules are extensible without core changes: implement the Gecole\Framework\Validation\Rule contract (or pass a callable) and register it with Validator::extend('name', …) from a service provider (parameters after the colon are passed through). make:rule scaffolds one.
  • Middleware — TrimStrings, HandleCors (with kernel-answered preflight), ThrottleRequests and Authenticate.
  • Console generators — make:controller|model|migration|command|provider|middleware|service|trait and app:key, scaffolding namespaced files under app.paths.

The ORM

Gecole\Framework\Database\Orm\Model is a small active record over the query builder. It keeps the legacy conventions by default (singular tables, epoch timestamps, JSON stored in varchar) and adds the parts that remove boilerplate:

final class Project extends Model
{
    protected string $table = 'project';

    protected array $fillable = ['designation', 'key', 'status'];

    protected array $casts = [
        'legacy_status' => 'int',
        'ips' => 'array',                  // JSON in a varchar column
        'status' => ProjectStatus::class,  // backed enum
        'starts_at' => 'datetime',         // DateTimeImmutable
        'price' => MoneyCast::class,       // CastsAttributes implementation
    ];

    public function clients(): BelongsToMany
    {
        return $this->belongsToMany(Client::class, 'client_project', 'project_id', 'client_id');
    }

    public function scopeActive(ModelQuery $query): ModelQuery
    {
        return $query->where('status', '=', self::STATUS_ACTIVE);
    }
}
  • Casts — int, float, decimal:n, string, bool, array/json, object, collection, date, datetime, custom_datetime:format, timestamp (epoch int), backed enums and classes implementing Casts\CastsAttributes. Custom casts declare constructor arguments after a colon.
  • Accessors/mutators — declarative protected function name(): Attribute (Attribute::get() / Attribute::set(), or the legacy getNameAttribute() / setNameAttribute() pair). $appends exposes computed values in toArray().
  • Timestamps — epoch integers by default; set protected string $timestampType = 'datetime' (and optionally $dateFormat) on a model whose created_at/updated_at/deleted_at columns are DATETIME.
  • Relations — belongsTo, hasOne, hasMany, hasManyThrough, belongsToMany with attach/detach/sync/syncWithoutDetaching/toggle, withPivotColumns() and updateExistingPivot(). Polymorphic relations are opt-in with the HasPolymorphicRelations trait (morphTo, morphMany, morphOne) so the base model stays small.
  • Eager loading — with('clients'), nested with('clients.tokens'), column-constrained with('tiers:id,code'), constrained with(['smsLogs' => fn ($q) => $q->where('status', 1)]), withCount(), withSum() and has()/whereHas()/doesntHave(). A listing resolves each relation with one where in (...) query, never one per row:

    $projects = Project::query()
        ->with('clients')
        ->withCount('smsLogs')
        ->whereHas('clients', fn ($q) => $q->where('status', 1))
        ->orderBy('id')
        ->get();
    
    $projects[0]->sms_logs_count;
    
  • Scopes and aggregates — scopeX() methods, count/sum/avg/min/max, pluck, value, exists, paginate (array shape), sole.
  • Events — creating, created, saving, saved, updating, updated, deleting, deleted, retrieved (plus restoring/restored), via Project::creating(fn ($project) => …) or Project::observe($observer); withoutEvents(), saveQuietly(), deleteQuietly().
  • Dirty tracking — isDirty(), getOriginal(), getChanges(), wasChanged(), syncOriginal(), refresh(), replicate().
  • Soft deletes — opt in with the SoftDeletes trait; delete() then writes deleted_at, reads are scoped automatically, and withTrashed(), onlyTrashed(), restore() and forceDelete() are available.
  • Large tables — chunk(), chunkById(), lazy() and cursor() stream rows instead of hydrating a million sms_log models at once.
  • Keyset pagination — cursorPaginate() uses the query order plus the primary key as a deterministic cursor; no COUNT(*) and no OFFSET scan.
  • Bulk writes — insertMany() compiles one multi-row statement per chunk, and upsertMany() uses the driver's native conflict clause.
  • Subqueries and CTEs — selectSub(), keyed addSelect(), joinSub(), fromSub(), whereExists()/whereNotExists(), withExpression() (including recursive CTEs), union()/unionAll().
  • Locking — lockForUpdate() and sharedLock() on the query builder, plus the opt-in HasOptimisticLocking trait (a version column and StaleModelException).
  • Collections — get() still returns arrays; collect($rows) wraps them in Support\Collection (map, filter, pluck, keyBy, groupBy, sum, avg, first, contains, …).

No relation, cast or event adds a dependency: it is still plain PHP over PDO.

Layout the framework expects

The layout is a convention, and every path is overridable — set app.paths.<name> in config/app.php (relative values resolve against the project root, absolute ones are used as given):

PathMethodDefaultOverride
project rootbasePath()—constructor argument
configconfigPath()config/app.paths.config
application classesappPath()src/App/app.paths.app
databasedatabasePath()database/app.paths.database
migrationsmigrationPath()database/migrations/app.paths.migrations
seedersseederPath()database/seeders/app.paths.seeders
publicpublicPath()public/app.paths.public
resourcesresourcePath()resources/app.paths.resources
viewsviewPath()resources/views/app.paths.views
storagestoragePath()storage/app.storage_path

Everything else — providers, middleware, session, logging, assets, server, pagination — is read from the application's config/app.php; see the starter package for a complete example.

Configuration keys the framework reads

KeyPurpose
app.name, app.env, app.debug, app.timezone, app.localeidentity of the application; debug decides how much an error response reveals
app.providersservice providers, register()d in order then boot()ed
app.middlewareglobal middleware, outermost first
app.middleware_groupsnamed middleware stacks (web, api, …) a route references by name
app.viewsdefault view engine, extra view paths, per-adapter options
app.apiprefix/middleware/name used when loading routes/api.php (starter convention)
app.sessioncookie name, lifetime, cookie options
app.loggingenabled, channel (daily/single), level, filename, requests
app.assetsmanifest, live, fallbacks
app.serverhost/port/workers for serve
app.paths, app.storage_pathdirectory overrides
database.default, database.connections.*, database.migrations_tablePDO connections and migration bookkeeping
auth.providerthe UserProvider class the session guard authenticates against

The framework reads database.migrations_table (default migrations), so an application sharing a database with a legacy system can keep its bookkeeping in its own table.

Deliberate choices

  • No runtime dependencies. No Composer packages are required to boot; the only requirement is PHP itself. Bundling is the application's business.
  • Errors are always rendered. With app.debug the response carries the exception class, file, line and a trimmed trace; without it, a generic message and a line in storage/logs. An errors.error view is used when the application provides one, otherwise the framework answers with a self-contained HTML page. API paths get a JSON envelope.
  • Explicit over magic. Bindings are declared in registerBaseBindings(), routes in a route file, middleware in config — nothing is discovered by scanning directories at runtime.

Internationalisation

A dependency-free translator ships with French and Arabic support and a clear way to add more. Lines live in resources/lang/{locale}.php as dot-notation arrays (resources/lang/fr.php → return ['home' => ['title' => 'Accueil']]), and app.locales lists the supported codes (app.locales_rtl marks right-to-left ones, ar by default). Translating is a global call:

'Cart has %count items' => echo trans('cart.items', ['count' => 2]);
  • trans($key, $replace, ?$locale) / __() substitute :placeholder values and fall back to app.fallback_locale before returning the key unchanged.
  • trans_choice() pluralises Laravel-style | segments ({0}…|[1,Inf]…).
  • translator() / app()->translator() expose locale(), setLocale(), hasLocale(), locales(), direction() (rtl/ltr) and choice().
  • SetLocale middleware (in the web group) picks the locale from ?lang=, the session, or Accept-Language, persists it, and shares locale, dir and locales to the views. Views can use trans()/__() directly, and the plain-PHP/Twig/Blade/Latte/Mustache engines expose them too.
  • End users add a language by writing resources/lang/xx.php and adding xx to app.locales — no code changes.

Publishing core migrations

Core features that need a table (such as API token auth) do not alter the database automatically. They ship migration templates under the framework's resources/migrations, and publish:migrations copies the ones you want into database/migrations as timestamped files:

php bin/console publish:migrations           # all core migrations
php bin/console publish:migrations tokens    # just the personal_access_tokens table
php bin/console publish:migrations --list    # what is available
php bin/console migrate                      # apply them like any migration

You can edit the published files; they run, roll back and report through the normal migrate/migrate:rollback/migrate:status commands.

Tests

The suite is dependency-free as well (php tests/run.php), and it boots a fixture application in tests/fixtures/app that uses nothing but the framework:

composer install
php tests/run.php             # everything
php tests/run.php http        # only cases whose path matches
php tests/run.php --stop      # stop at the first failure

Coverage: container/config/paths, router matching, route files + middleware groups, query-builder SQL, validation rules, support helpers, view rendering (engine resolution, custom engines, stacked paths, asset fallbacks), the middleware pipeline, the HTTP kernel end to end (routing → middleware → view, error rendering, 404/405, JSON errors), the console (command list, route:list, help, output degradation) and the database layer against SQLite (schema building, migrations, the query builder, and the ORM: casts, accessors, eager loading query counts, relation aggregates, events, soft deletes, chunking and the collection, plus timestamps, subqueries/CTEs, bulk upserts, keyset pagination, hasManyThrough, polymorphic relations and optimistic locking).

Consumers

  • gecole/starter — a runnable skeleton application plus a scaffolder.