First Project Tutorial
This guide assumes PHP ≥ 8.4, Composer, and MySQL 8 running locally.
Step | Goal | Packages used |
---|---|---|
1 | New project skeleton | Core, CLI |
2 | Hello-world route | Router, Dev Server |
3 | Blade-style view | Template |
4 | Entity + migration | Entity, Database, Migration |
5 | Query layer | Query |
6 | JWT login & auth guard | Auth |
7 | Validation & i18n | Validation, I18n |
8 | Telemetry & events | Telemetry, Events |
9 | “prod-ish” build | Dev-Server, Config, CLI |
1 · Create & bootstrap
composer create-project monkeyscloud/monkeyslegion-skeleton blog
cd blog
php vendor/bin/ml key:generate # RSA keypair for JWT
The skeleton wires Core, DI, CLI, Dev Server.
Open config/app.mlc and set app.name = "Blog".
2 · Hello, world
Create a controller:
php vendor/bin/ml make:controller HomeController
src/Http/Controller/HomeController.php
use MonkeysLegion\Router\Attribute\Route;
use Laminas\Diactoros\Response\HtmlResponse;
final class HomeController
{
#[Route('GET', '/')]
public function index(): HtmlResponse
{
return new HtmlResponse('<h1>Hello MonkeysLegion!</h1>');
}
}
Start the dev server:
composer serve
Visit http://localhost:8000—you should see the greeting.
3 · Add a template view
resources/views/home.ml.php
<x-layout>
<x-slot:title>Hello ✨</x-slot:title>
<p>Served at {{ date('H:i:s') }}</p>
</x-layout>
A minimal component:
app/View/Components/Layout.php
namespace App\View\Components;
use MonkeysLegion\Template\Component;
final class Layout extends Component
{
public function render(): string
{
return <<<ML
<!doctype html>
<title>{{ \$this->slot('title') }}</title>
<body>{{ \$this->slot('default') }}</body>
ML;
}
}
Update the controller:
public function index(MLView $view): HtmlResponse
{
return new HtmlResponse($view->render('home'));
}
Save any file—dev server auto-reloads.
4 · Persist data with Entity + Migration
Generate:
php vendor/bin/ml make:entity Post
Edit the new entity:
#[Entity(table: 'posts')]
final class Post
{
#[Id(strategy: 'auto')] public int $id;
#[Field(type: 'string', length:160)] public string $title;
#[Field(type: 'text')] public string $body;
#[Field(type: 'datetime')] public \DateTimeImmutable $publishedAt;
}
Create SQL:
php vendor/bin/ml migrate # autodetects schema diff
A file like 2025_06_11_…_CreatePosts.php is generated & executed.
5 · Query layer
src/Repository/PostRepository.php
final class PostRepository extends EntityRepository
{
protected string $table = 'posts';
protected string $entityClass = Post::class;
public function latest(int $limit = 10): array
{
return $this->qb->select('*')
->from($this->table)
->orderBy('publishedAt','DESC')
->limit($limit)
->fetchAll(Post::class);
}
}
Controller route:
#[Route('GET','/posts')]
public function list(PostRepository $posts, MLView $view): ResponseInterface
{
return new HtmlResponse($view->render('posts/list', [
'posts' => $posts->latest()
]));
}
6 · Auth: register, login, guard
DTO with validation rules:
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class RegisterUser
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 6)]
public string $password;
}
final class LoginUser
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
public string $password;
}
Controller:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\RegisterUser;
use App\Dto\LoginUser;
use MonkeysLegion\Auth\AuthService;
use MonkeysLegion\Http\Message\JsonResponse;
use MonkeysLegion\Router\Attributes\Route;
final class AuthController
{
#[Route('POST', '/auth/register')]
public function register(
RegisterUser $input,
AuthService $auth
): JsonResponse {
// throws RuntimeException if email already exists
$user = $auth->register($input->email, $input->password);
return new JsonResponse([
'id' => $user->getId(),
'email' => $user->getEmail(),
], 201);
}
#[Route('POST', '/auth/login')]
public function login(
LoginUser $input,
AuthService $auth
): JsonResponse {
// throws RuntimeException on bad credentials
$token = $auth->login($input->email, $input->password);
return new JsonResponse([
'token' => $token,
]);
}
}
Protect routes:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Post;
use MonkeysLegion\Http\Message\JsonResponse;
use MonkeysLegion\Router\Attributes\Can;
use MonkeysLegion\Router\Attributes\Route;
final class PostController
{
#[Can('edit', Post::class)]
#[Route('PUT', '/posts/{id}')]
public function edit(Post $post): JsonResponse
{
// $post is autoloaded by ID, and guard has already confirmed 'edit' permission
// …perform update…
return new JsonResponse(['ok' => true]);
}
}
Add middleware order in bootstrap/http.php:
<?php
// bootstrap/http.php
use MonkeysLegion\Auth\JwtAuthMiddleware;
use MonkeysLegion\Auth\AuthorizationMiddleware;
use MonkeysLegion\Http\Kernel;
$kernel = new Kernel();
// 1) Validate & decode the Bearer token (populates user in Request attributes)
$kernel->add(new JwtAuthMiddleware($jwtService));
// 2) Enforce any #[Can] annotations
$kernel->add(new AuthorizationMiddleware($authorizationService));
return $kernel;
7 · Validation & i18n in one shot
ValidationMiddleware binds DTOs & returns 422 on failures.
Use @lang('posts.count') in templates; Translator picks locale from request.
Add resources/lang/es.json to get instant Spanish pages.
8 · Telemetry & events
$kernel->add(new LatencyMiddleware($metrics));
$dispatcher->dispatch(new UserRegistered($id,$email));
Expose Prometheus stats at /metrics, scrape with Prometheus, chart in Grafana.
9 · Ship it
Config → create config/env/prod.mlc (app.debug? = false).
Build → composer install --no-dev --optimize-autoloader.
Serve → point Nginx to public/index.php or run in a Docker container that calls php artisan serve equivalent.
Migrate → php vendor/bin/ml migrate --env=prod.