meita/joe

Joe MVC core using JSONBolt and Shams.

Maintainers

Details

github.com/EngMEita/joe

Source

Issues

Installs: 1

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/meita/joe

1.0.0 2025-12-21 11:17 UTC

This package is auto-updated.

Last update: 2025-12-21 11:22:58 UTC


README

Joe is a lightweight MVC core for PHP projects built around:

  • meita/jsonbolt for the database engine
  • meita/shams for the template engine

Requirements

  • PHP 8.0+
  • ext-json
  • Composer

Installation

composer require meita/joe meita/jsonbolt meita/shams

Project layout (recommended)

your-app/
  app/
    Controllers/
    Models/
  components/
  public/
    index.php
  storage/
    data/
    shams-cache/
  views/
    home.shams
    layouts/
      app.shams

Bootstrap

public/index.php

<?php

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

use Meita\Joe\Application;
use Meita\Joe\Database\JsonBoltDatabase;
use Meita\Joe\View\ShamsView;
use Meita\JsonBolt\Cache\FileCache;
use Meita\JsonBolt\Database as JsonBolt;
use Meita\Shams\Shams;

$app = new Application(dirname(__DIR__));

$shams = new Shams([
    'views_path' => $app->path('views'),
    'cache_path' => $app->path('storage/shams-cache'),
    'components_path' => $app->path('components'),
    'auto_reload' => true,
]);
$app->setView(new ShamsView($shams));

$db = new JsonBolt($app->path('storage/data'), [
    'cache' => new FileCache($app->path('storage/cache')),
    'cache_ttl' => 300,
]);
$app->setDatabase(new JsonBoltDatabase($db));

$app->routes()->get('/', function () use ($app) {
    return $app->view()->render('home', ['title' => 'Welcome']);
});

$app->run();

Routing

$routes = $app->routes();

$routes->get('/', fn () => 'Hello');
$routes->post('/users', [App\Controllers\UserController::class, 'store']);
$routes->put('/users/{id}', [App\Controllers\UserController::class, 'update']);
$routes->delete('/users/{id}', 'App\Controllers\UserController@destroy');
$routes->any('/health', fn () => ['ok' => true]);

Route groups

$routes->group(['prefix' => '/api'], function ($routes) {
    $routes->get('/users', [App\Controllers\Api\UserController::class, 'index']);
    $routes->get('/users/{id}', [App\Controllers\Api\UserController::class, 'show']);
});

Route parameters

$routes->get('/users/{id}', function (string $id) {
    return ['id' => $id];
});

Route middleware

$routes->get('/admin', [App\Controllers\AdminController::class, 'index'])
    ->middleware(function ($request, $next, $app) {
        if (!$request->cookie('admin')) {
            return \Meita\Joe\Http\Response::redirect('/login');
        }

        return $next($request);
    });

Middleware can also be applied to a group:

$routes->group(['prefix' => '/admin', 'middleware' => [
    function ($request, $next, $app) {
        if (!$request->cookie('admin')) {
            return \Meita\Joe\Http\Response::redirect('/login');
        }

        return $next($request);
    },
]], function ($routes) {
    $routes->get('/users', [App\Controllers\AdminController::class, 'users']);
});

Handler return values

Routes and controllers may return:

  • Meita\Joe\Http\Response (sent as-is)
  • array (JSON response)
  • string or any scalar (HTML response)

Requests

use Meita\Joe\Http\Request;

$routes->post('/users', function (Request $request) {
    $email = $request->input('email');
    $payload = $request->json();
    return ['email' => $email, 'payload' => $payload];
});

Available helpers:

  • method(), path()
  • query(), body(), all(), input($key, $default)
  • header($name), headers()
  • cookie($name), cookies()
  • files()
  • raw(), json()
  • attributes(), attribute($key, $default) for route params

Method override is supported via X-HTTP-Method-Override header or _method in form/query data (POST requests).

Responses

use Meita\Joe\Http\Response;

return Response::html('<h1>Hello</h1>', 200);
return Response::json(['ok' => true], 200);
return Response::redirect('/login');

Controllers

<?php

namespace App\Controllers;

use Meita\Joe\Http\Controller;
use Meita\Joe\Http\Response;

final class HomeController extends Controller
{
    public function index(): Response
    {
        return $this->view('home', ['title' => 'Hello']);
    }

    public function profile(): Response
    {
        $user = $this->db()->collection('users')->find(1);
        return $this->json($user ?? []);
    }
}
$app->routes()->get('/', [App\Controllers\HomeController::class, 'index']);

Views (Shams)

Joe uses Shams as the view engine. Configure it and pass the adapter:

use Meita\Joe\View\ShamsView;
use Meita\Shams\Shams;

$shams = new Shams([
    'views_path' => $app->path('views'),
    'cache_path' => $app->path('storage/shams-cache'),
    'components_path' => $app->path('components'),
    'asset_url' => '/assets',
]);

$app->setView(new ShamsView($shams));

Example template views/home.shams:

@extends('layouts.app')

@section('title', $title)

@section('content')
  <h1>{{ $title }}</h1>
@endsection

Database (JSONBolt)

Joe wraps JSONBolt via JsonBoltDatabase:

use Meita\Joe\Database\JsonBoltDatabase;
use Meita\JsonBolt\Database as JsonBolt;

$db = new JsonBolt($app->path('storage/data'));
$app->setDatabase(new JsonBoltDatabase($db));

Collections are used like JSONBolt:

$users = $app->db()->collection('users');
$user = $users->insert(['name' => 'Sara', 'email' => 'sara@example.com']);
$found = $users->find($user['id']);

Models

Create a model per collection:

<?php

namespace App\Models;

use Meita\Joe\Database\Model;

final class User extends Model
{
    public static function collection(): string
    {
        return 'users';
    }
}
$users = new App\Models\User($app->db());
$users->create(['name' => 'Mona']);
$users->where('active', true)->orderBy('name')->get();

Middleware

Middleware runs before the router and can short-circuit the request:

use Meita\Joe\Application;
use Meita\Joe\Http\Request;

$app->addMiddleware(function (Request $request, callable $next, Application $app) {
    if ($request->path() === '/health') {
        return ['ok' => true];
    }

    return $next($request);
});

Dependency injection

The container can resolve class-hinted arguments in route handlers:

use Meita\Joe\Container\Container;
use Meita\Joe\Http\Request;

$app->container()->bind(MyService::class, fn (Container $c) => new MyService());

$app->routes()->get('/ping', function (Request $request, MyService $service) {
    return ['pong' => true];
});

Configuration

$app->config()->set('app.env', 'local');
$env = $app->config()->get('app.env', 'production');

Error handling

Joe keeps error handling simple. Add a top-level middleware to convert exceptions:

use Meita\Joe\Http\Response;

$app->addMiddleware(function ($request, $next) {
    try {
        return $next($request);
    } catch (Throwable $e) {
        return Response::html('Server Error', 500);
    }
});

Deployment

  • Point your web server document root to public/.
  • Ensure storage/ is writable.

Optional .htaccess for Apache:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

License

MIT