Anyone who has built an admin panel in PHP knows how this story usually ends. At the beginning, everything is simple: a table, a form, a couple of filters. Then real requirements arrive — relationships between data, more complex flows, partial updates — and the classic “table + form” CRUD starts to crack.
It’s not CRUD’s fault. The problem is that admin interfaces are not just CRUD.
A real admin needs to see data in different contexts, navigate relationships without losing state, and edit related entities quickly.
These reflections led me to explore different approaches, until I decided to build a system from scratch, outside of the most popular frameworks. This is where I ended up.
A Different Way to Think About the Problem
Instead of thinking “I need to build a page”, I started thinking “I need to describe a view of this data”.
This led me to builders: PHP objects that describe what to display and how. Not frontend components, not magic widgets — just PHP structures that generate HTML, handle queries, and return either HTML or JSON responses.
The code stays PHP. The flow stays request → processing → response.
A Concrete Example: Posts
Let’s start with the simplest case: a module to manage posts with a title and content.
The Model defines the data structure
class PostsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__posts')
->id()
->string('title')->index()
->text('content')->formType('editor');
}
#[Validate('title')]
public function validateTitle($current_record_obj): string {
$value = $current_record_obj->title;
if (strlen($value) < 5) {
return 'Title must be at least 5 characters long';
}
return '';
}
}
The model is the source of truth. It declares the table, the fields, how they should appear in forms, and where validation belongs — in the model, where it actually makes sense.
The Controller uses builders to generate views
class PostsController extends AbstractController
{
#[RequestAction('home')]
public function postsList() {
$tableBuilder = TableBuilder::create($this->model, 'idTablePosts')
->field('content')->truncate(50)
->field('title')->link('?page=posts&action=edit&id=%id%')
->setDefaultActions();
$response = array_merge($this->getCommonData(), $tableBuilder->getResponse());
Response::render(MILK_DIR . '/Theme/SharedViews/list_page.php', $response);
}
#[RequestAction('edit')]
public function postEdit() {
$response = $this->getCommonData();
$response['form'] = FormBuilder::create($this->model, $this->page)->getForm();
Response::render(MILK_DIR . '/Theme/SharedViews/edit_page.php', $response);
}
}
TableBuilder already knows how to handle search, pagination, and sorting. FormBuilder knows which fields to show and how to validate them. You describe what you want — the builders generate the rest.
Views are plain PHP templates — no templating engines, no special syntax. If you need to change something, you open the file and edit the HTML directly.
Where Things Get Complicated: Relationships
Simple CRUD works everywhere. Problems start when entities are related.
Recipes
└─ Comments
When relationships become interactive, the usual options are either: splitting everything into multiple pages (losing fluidity), or relying on heavy frameworks that manage state for you (losing transparency and control).
Tools like Laravel Nova or Filament are powerful and work well in structured teams. But when flows become very specific, it can be hard to understand what is really happening between backend and frontend.
I wanted a different approach: keep everything in PHP, use fetch to avoid page reloads, but without hiding what’s going on.
Recipes: CRUD with Relationships, Fully in Fetch
This is the full example: recipes with comments, two interface levels, no page reloads.
Recipe model
class RecipeModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipes')
->id()
->hasMany('comments', RecipeCommentsModel::class, 'recipe_id')
->image('image')
->title('name')->index()
->text('ingredients')->formType('textarea')
->select('difficulty', ['Easy', 'Medium', 'Hard']);
}
}
In just a few lines, it defines the table, the one-to-many relationship, and the field types. The builders automatically know how to handle uploads, selects, and text areas.
Comments model
class RecipeCommentsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipe_comments')
->id()
->int('recipe_id')->formType('hidden')
->text('comment');
}
}
Everything else — lists, offcanvas forms, modals, automatic refresh — is built using the same builders and the same mental model.
Why This Approach
This is not another framework. It’s a way of thinking about admin panels.
Builders reduce boilerplate, but the code remains readable PHP. If a builder doesn’t do what you need, you can always drop down to plain PHP.
JSON as a contract between backend and frontend is easy to debug. No hidden state, no complex lifecycles. PHP sends instructions, the browser executes them.
Most importantly, relationships are handled with the same tools as basic CRUD. There’s no separate system to learn just for relations.
Who This Is For
If you work with Laravel and you’re happy with it, this is probably not for you.
But if you find yourself in one of these situations:
You maintain existing PHP projects that can’t be migrated
You work on internal tools, CRMs, or management systems
You need to understand the code quickly, even months later
You don’t want to chase complex ecosystems
You want PHP that looks and feels like PHP
Then it might be worth rethinking how we build admin interfaces.
Admin panels are not public websites. They are work tools. And the best tools are those that do their job well, remain understandable over time, and don’t force you to rewrite everything when requirements change.
These reflections come from the development of MilkAdmin, a PHP admin panel system I actively maintain. Code, demos, and documentation are available at milkadmin.org. MIT licensed.



Top comments (0)