AdminYard is a lightweight PHP library for building admin panels without heavy dependencies such as frameworks, templating engines, or ORMs. It provides a declarative configuration in object style for defining entities, fields, and their properties. With AdminYard, you can quickly set up CRUD (Create, Read, Update, Delete) interfaces for your database tables and customize them according to your needs.
AdminYard simplifies the process of creating typical admin interfaces, allowing you to focus on developing core functionality. It does not attempt to create its own abstraction with as many features as possible. Instead, it addresses common admin tasks while providing enough extension points to customize it for your specific project.
When developing AdminYard, I took inspiration from EasyAdmin. I wanted to use it for one of my own projects, but I didn't want to pull in major dependencies like Symfony, Doctrine, or Twig. So, I tried to make a similar product without those dependencies. It can be useful for embedding into existing legacy projects, where adding a new framework and ORM is not so easy. If you are starting a new project from scratch, I recommend that you consider using Symfony, Doctrine and EasyAdmin first.
To install AdminYard, you can use Composer:
composer require s2/admin-yard
Here are configuration examples with explanations. You can also see a more complete example of working demo application.
Once installed, you can start using AdminYard by creating an instance of AdminConfig and configuring it with your entity settings. Then, create an instance of AdminPanel passing the AdminConfig instance. Use the handleRequest method to handle incoming requests and generate the admin panel HTML.
<?php
use S2\AdminYard\DefaultAdminFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
// Config for admin panel, see below
$adminConfig = require 'admin_config.php';
// Typical AdminYard services initialization.
// You can use DI instead and override services if required.
$pdo = new PDO('mysql:host=localhost;dbname=adminyard', 'username', 'passwd');
$adminPanel = DefaultAdminFactory::createAdminPanel($adminConfig, $pdo, require 'translations/en.php', 'en');
// AdminYard uses Symfony HTTP Foundation component.
// Sessions are required to store flash messages.
// new Session() stands for native PHP sessions. You can provide an alternative session storage.
$request = Request::createFromGlobals();
$request->setSession(new Session());
$response = $adminPanel->handleRequest($request);
$response->send();
<?php
declare(strict_types=1);
use S2\AdminYard\Config\AdminConfig;
use S2\AdminYard\Config\DbColumnFieldType;
use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\FieldConfig;
use S2\AdminYard\Config\Filter;
use S2\AdminYard\Config\FilterLinkTo;
use S2\AdminYard\Config\LinkTo;
use S2\AdminYard\Database\PdoDataProvider;
use S2\AdminYard\Event\AfterSaveEvent;
use S2\AdminYard\Event\BeforeDeleteEvent;
use S2\AdminYard\Event\BeforeSaveEvent;
use S2\AdminYard\Validator\NotBlank;
use S2\AdminYard\Validator\Length;
$adminConfig = new AdminConfig();
$commentConfig = new EntityConfig(
'Comment', // Entity name in interface
'comments' // Database table name
);
$postEntity = (new EntityConfig('Post', 'posts'))
->addField(new FieldConfig(
name: 'id',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key
// Show ID only on list and show screens
useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW]
))
->addField(new FieldConfig(
name: 'title',
// DATA_TYPE_STRING may be omitted as it is default:
// type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING),
// Form control must be defined since new and edit screens are not excluded in useOnActions
control: 'input', // Input field for title
validators: [new Length(max: 80)], // Form validators may be supplied
sortable: true, // Allow sorting on the list screen
actionOnClick: 'edit' // Link from cell on the list screen
))
->addField(new FieldConfig(
name: 'text',
control: 'textarea', // Textarea for post content
// All screens except list
useOnActions: [FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT, FieldConfig::ACTION_NEW]
))
->addField(new FieldConfig(
name: 'created_at',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Timestamp field
control: 'datetime', // Date and time picker
sortable: true // Allow sorting by creation date
))
->addField(new FieldConfig(
name: 'comments',
// Special config for one-to-many association. Will be displayed on the list and show screens
// as a link to the comments list screen with a filter on posts applied.
type: new LinkedByFieldType(
$commentConfig,
'CASE WHEN COUNT(*) > 0 THEN COUNT(*) ELSE NULL END', // used as text in link
'post_id'
),
sortable: true
))
->addFilter(new Filter(
'search',
'Fulltext Search',
'search_input',
'title LIKE %1$s OR text LIKE %1$s',
fn(string $value) => $value !== '' ? '%' . $value . '%' : null // Transformer for PDO parameter
))
;
// Fields and filters configuration for "Comment"
$commentConfig
->addField(new FieldConfig(
name: 'id',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key
useOnActions: [] // Do not show on any screen
))
->addField($postIdField = new FieldConfig(
name: 'post_id',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT), // Foreign key to post
control: 'autocomplete', // Autocomplete control for selecting post
validators: [new NotBlank()], // Ensure post_id is not blank
sortable: true, // Allow sorting by title
// Special config for one-to-many association. Will be displayed on the list and show screens
// as a link to the post. "CONCAT('#', id, ' ', title)" is used as a link text.
linkToEntity: new LinkTo($postEntity, "CONCAT('#', id, ' ', title)"),
// Disallow on edit screen, post may be chosen on comment creation only.
useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_NEW]
))
->addField(new FieldConfig(
name: 'name',
control: 'input', // Input field for commenter's name
validators: [new NotBlank(), new Length(max: 50)],
inlineEdit: true, // Allow to edit commentator's name on the list screen
))
->addField(new FieldConfig(
name: 'email',
control: 'email_input',
validators: [new Length(max: 80)], // Max length validator
inlineEdit: true,
))
->addField(new FieldConfig(
name: 'comment_text',
control: 'textarea',
))
->addField(new FieldConfig(
name: 'created_at',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Timestamp field
control: 'datetime', // Date and time picker
sortable: true // Allow sorting by creation date on the list screen
))
->addField(new FieldConfig(
name: 'status_code',
// defaultValue is used for new entities when the creating form has no corresponding field
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'),
control: 'radio', // Radio buttons for status selection
options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'],
inlineEdit: true,
// Disallow on new screen
useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT]
))
->addFilter(new Filter(
'search',
'Fulltext Search',
'search_input',
'name LIKE %1$s OR email LIKE %1$s OR comment_text LIKE %1$s',
fn(string $value) => $value !== '' ? '%' . $value . '%' : null
))
->addFilter(new FilterLinkTo(
$postIdField, // Filter comments by a post on the list screen
'Post',
))
->addFilter(new Filter(
'created_from',
'Created after',
'date',
'created_at >= %1$s', // Filter comments created after a certain date
))
->addFilter(new Filter(
'created_to',
'Created before',
'date',
'created_at < %1$s', // Filter comments created before a certain date
))
->addFilter(new Filter(
'statuses',
'Status',
'checkbox_array', // Several statuses can be chosen at once
'status_code IN (%1$s)', // Filter comments by status
options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected']
));
// Add entities to admin config
$adminConfig
->addEntity($postEntity)
->addEntity($commentConfig);
return $adminConfig;
An entity field can either directly map to a column in the corresponding table or be virtual, calculated on-the-fly based on certain rules.
Continuing with the previous example, suppose posts have a many-to-many relationship with tags in the posts_tags table. If we want to display the number of related posts in the list of tags, we can use the following construction:
<?php
use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\Filter;
$tagConfig = new EntityConfig('Tag', 'tags');
$tagConfig
->addField(new FieldConfig(
name: 'name',
control: 'input',
))
->addField(new FieldConfig(
name: 'used_in_posts', // Arbitrary field name
type: new VirtualFieldType(
// Query evaluates the content of the virtual field
'SELECT CAST(COUNT(*) AS CHAR) FROM posts_tags AS pt WHERE pt.tag_id = entity.id',
// We can define a link to the post list.
// To make this work, a filter on tags must be set up for posts, see below
new LinkToEntityParams('Post', ['tags'], ['name' /* Tag property name, i.e. tags.name */])
),
// Read-only field, new and edit actions are disabled.
useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW]
))
;
$postConfig
->addFilter(
new Filter(
name: 'tags',
label: 'Tags',
control: 'search_input',
whereSqlExprPattern: 'id IN (SELECT pt.post_id FROM posts_tags AS pt JOIN tags AS t ON t.id = pt.tag_id WHERE t.name LIKE %1$s)',
fn(string $value) => $value !== '' ? '%' . $value . '%' : null
)
)
;
In this example, the virtual field used_in_posts
is declared as read-only.
We cannot edit the relationships in the posts_tags
table through it.
AdminYard does not have knowledge of the system users, their roles, or permissions. However, due to its dynamic and flexible configuration, you can program the differences in roles and permissions within the configuration itself. Let's look at some examples.
Controlling access to actions based on role for all entities at once:
$postEntity->setEnabledActions([
FieldConfig::ACTION_LIST,
...isGranted('author') ? [FieldConfig::ACTION_EDIT, FieldConfig::ACTION_DELETE] : [],
]);
To control access not for all entities but at the row level, one can specify additional conditions in a LogicalExpression, which are included in the WHERE clause of queries and restrict access at the row level for reading (actions list
and show
) and writing (actions edit
and delete
):
// If the power_user role is not granted, show only approved comments
if (!isGranted('power_user')) {
$commentEntity->setReadAccessControl(new LogicalExpression('status_code', 'approved'));
}
The conditions can be more complex. They can include external parameters like $currentUserId
, as well as values from columns in the table:
if (!isGranted('editor')) {
// If the editor role is not granted, the user can only see their own posts
// or those that are already published.
$postEntity->setReadAccessControl(
new LogicalExpression('read_access_control_user_id', $currentUserId, "status_code = 'published' OR user_id = %s")
);
// If the editor role is not granted, the user can only edit or delete their own posts.
$postEntity->setWriteAccessControl(new LogicalExpression('user_id', $currentUserId));
}
Besides restricting access to entire rows, one can control access to individual fields.
$commentEntity->addField(new FieldConfig(
name: 'email',
control: 'email_input',
validators: [new Length(max: 80)],
// Hide the field value from users without sufficient access level
useOnActions: isGranted(power_user) ? [FieldConfig::ACTION_EDIT, FieldConfig::ACTION_LIST] : [],
));
$commentEntity->addField(new FieldConfig(
name: 'status_code',
type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'),
control: 'radio',
options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'],
// Allow inline editing of this field on the list screen for users with the moderator role.
// Inline editing does not take into account the condition specified in setWriteAccessControl,
// to allow partial editing of the entity for users without full editing rights.
inlineEdit: isGranted('moderator'),
useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT]
));
AdminYard operates with three levels of data representation:
- HTTP: HTML code of forms sent and data received in POST requests.
- Normalized data passed between code components in a controller.
- Persisted data in the database.
When transitioning between these levels, data is transformed based on the entity configuration.
To transform between the HTTP level and normalized data, form controls are used.
To transform between normalized data and data persisted in the database, dataType
is used.
The dataType
value must be chosen based on the column type in the database
and the meaning of the data stored in it.
For example, if you have a column in the database of type VARCHAR
, you cannot specify the dataType as bool
,
since the TypeTransformer will convert all values to integer 0 or 1 when writing to the database,
which is not compatible with string data types.
It should be understood that not all form controls are compatible with normalized data
produced by the TypeTransformer when reading from the database based on dataType
.
Here are some recommendations for choosing dataTypes based on the database column types and desired form control:
DataType | Control | Normalized type in PHP | Database column types |
---|---|---|---|
string | input textarea search_input email_input select radio |
string | TEXT (incl. VARCHAR) |
int | int_input select radio |
string ('' -> NULL) | INT, TEXT |
float | float_input | string ('' -> NULL) | TEXT, DECIMAL |
bool | checkbox | bool | INT, BOOLEAN |
date | date | ?string | DATE, TEXT |
timestamp | datetime | ?DateTimeImmutable | TIMESTAMP, DATETIME |
unixtime | datetime | ?DateTimeImmutable | INT |
As you might have noticed, among the normalized data types, strings are often used instead of specialized types, particularly for int and float. This is done for two reasons. First, the control used for entering numbers is a regular input, and the data entered into it is transmitted from the browser to the server as a string. Therefore, the intermediate values are chosen to be strings. Second, transmitting data as a string without intermediate conversion to float avoids potential precision loss when working with floating-point numbers.
All fields in the configuration definition are divided into two major types: column fields and virtual fields.
They are described by the DbColumnFieldType and VirtualFieldType classes.
Column fields directly correspond to columns in database tables.
Many-to-one associations are also considered column fields,
as they are usually represented by references like entity_id
.
AdminYard supports all CRUD operations with column fields.
Additionally, AdminYard supports one-to-many associations through the LinkedByFieldType,
which is a subclass of VirtualFieldType.
To use VirtualFieldType, you need to write an SQL query that evaluates the content displayed in the virtual field.
When executing SELECT queries to the database, one need to retrieve both column field values and virtual field values.
To avoid conflicts, column field names are prefixed with column_
, and virtual field names are prefixed with virtual_
.
Without this separation, many-to-one associations using new LinkTo($postEntity, "CONCAT('#', id, ' ', title)")
would not work, as both the content for the link and the entity identifier for the link address
need to be retrieved simultaneously.
These prefixes are not added to form control names or to keys in arrays when passing data
from POST requests to modifying database queries.
To assign tags to posts, let's create a virtual field tags
in the posts, which can accept tags separated by commas.
AdminYard doesn't have built-in functionality for this, but it has events at various points in the data flow
that allow this functionality to be implemented manually.
<?php
use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\FieldConfig;
use S2\AdminYard\Config\VirtualFieldType;
use S2\AdminYard\Database\Key;
use S2\AdminYard\Database\PdoDataProvider;
use S2\AdminYard\Event\AfterSaveEvent;
use S2\AdminYard\Event\BeforeDeleteEvent;
use S2\AdminYard\Event\AfterLoadEvent;
use S2\AdminYard\Event\BeforeSaveEvent;
$postConfig
->addField(new FieldConfig(
name: 'tags',
// Virtual field, SQL query evaluates the content for list and show screens
type: new VirtualFieldType('SELECT GROUP_CONCAT(t.name SEPARATOR ", ") FROM tags AS t JOIN posts_tags AS pt ON t.id = pt.tag_id WHERE pt.post_id = entity.id'),
// Form control for new and edit forms
control: 'input',
))
->addListener([EntityConfig::EVENT_AFTER_EDIT_FETCH], function (AfterLoadEvent $event) {
if (\is_array($event->data)) {
// Convert NULL to an empty string when the edit form is filled with current data.
// It is required since TypeTransformer is not applied to virtual fields (no dataType).
// 'virtual_' prefix is used for virtual fields as explained earlier.
$event->data['virtual_tags'] = (string)$event->data['virtual_tags'];
}
})
->addListener([EntityConfig::EVENT_BEFORE_UPDATE, EntityConfig::EVENT_BEFORE_CREATE], function (BeforeSaveEvent $event) {
// Save the tags to context for later use and remove before updating and inserting.
$event->context['tags'] = $event->data['tags'];
unset($event->data['tags']);
})
->addListener([EntityConfig::EVENT_AFTER_UPDATE, EntityConfig::EVENT_AFTER_CREATE], function (AfterSaveEvent $event) {
// Process the saved tags. Convert the comma-separated string to an array to store in the many-to-many relation.
$tagStr = $event->context['tags'];
$tags = array_map(static fn(string $tag) => trim($tag), explode(',', $tagStr));
$tags = array_filter($tags, static fn(string $tag) => $tag !== '');
// Fetching tag IDs, creating new tags if required
$newTagIds = tagIdsFromTags($event->dataProvider, $tags);
// Fetching old links
$existingLinks = $event->dataProvider->getEntityList('posts_tags', [
'post_id' => FieldConfig::DATA_TYPE_INT,
'tag_id' => FieldConfig::DATA_TYPE_INT,
], conditions: [new Condition('post_id', $event->primaryKey->getIntId())]);
$existingTagIds = array_column($existingLinks, 'column_tag_id');
// Check if the new tag list differs from the old one
if (implode(',', $existingTagIds) !== implode(',', $newTagIds)) {
// Remove all old links
$event->dataProvider->deleteEntity(
'posts_tags',
['post_id' => FieldConfig::DATA_TYPE_INT],
new Key(['post_id' => $event->primaryKey->getIntId()]),
[],
);
// And create new ones
foreach ($newTagIds as $tagId) {
$event->dataProvider->createEntity('posts_tags', [
'post_id' => FieldConfig::DATA_TYPE_INT,
'tag_id' => FieldConfig::DATA_TYPE_INT,
], ['post_id' => $event->primaryKey->getIntId(), 'tag_id' => $tagId]);
}
}
})
->addListener(EntityConfig::EVENT_BEFORE_DELETE, function (BeforeDeleteEvent $event) {
$event->dataProvider->deleteEntity(
'posts_tags',
['post_id' => FieldConfig::DATA_TYPE_INT],
new Key(['post_id' => $event->primaryKey->getIntId()]),
[],
);
})
;
// Fetching tag IDs, creating new tags if required
function tagIdsFromTags(PdoDataProvider $dataProvider, array $tags): array
{
$existingTags = $dataProvider->getEntityList('tags', [
'name' => FieldConfig::DATA_TYPE_STRING,
'id' => FieldConfig::DATA_TYPE_INT,
], conditions: [new Condition('name', array_map(static fn(string $tag) => mb_strtolower($tag), $tags), 'LOWER(name) IN (%s)')]);
$existingTagsMap = array_column($existingTags, 'column_name', 'column_id');
$existingTagsMap = array_map(static fn(string $tag) => mb_strtolower($tag), $existingTagsMap);
$existingTagsMap = array_flip($existingTagsMap);
$tagIds = [];
foreach ($tags as $tag) {
if (!isset($existingTagsMap[mb_strtolower($tag)])) {
$dataProvider->createEntity('tags', ['name' => FieldConfig::DATA_TYPE_STRING], ['name' => $tag]);
$newTagId = $dataProvider->lastInsertId();
} else {
$newTagId = $existingTagsMap[mb_strtolower($tag)];
}
$tagIds[] = $newTagId;
}
return $tagIds;
}
If you have suggestions for improvement, please submit a pull request.
AdminYard is released under the MIT License. See LICENSE for details.