s2 / admin-yard
Simple admin panel without heavy dependencies
Requires
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/event-dispatcher: ^5.4 || ^6.0 || ^7.0
- symfony/http-foundation: ^5.4 || ^6.0 || ^7.0
- symfony/translation-contracts: ^3.5
Requires (Dev)
- codeception/codeception: ^5.1
- codeception/module-asserts: ^3.0
- codeception/module-phpbrowser: ^3.0
- league/commonmark: ^2.4
- symfony/error-handler: ^7.0
Suggests
- ext-pdo: *
This package is auto-updated.
Last update: 2025-02-16 09:09:49 UTC
README
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.
Installation
To install AdminYard, you can use Composer:
composer require s2/admin-yard
Usage
Here are configuration examples with explanations. You can also see a more complete example of working demo application.
Integration
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();
Basic config example for fields, filters, many-to-one and one-to-many associations
<?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;
Advanced example with virtual fields and many-to-many associations
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\FieldConfig; use S2\AdminYard\Config\LinkToEntityParams; use S2\AdminYard\Config\Filter; use S2\AdminYard\Config\VirtualFieldType; $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.
Access control
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] ));
Architecture
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
.
Choosing controls and dataTypes
Here are some recommendations for choosing dataTypes based on the database column types and desired form control:
Note on Normalized Types in PHP
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.
Column fields and virtual fields
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.
Config example: editable virtual fields via event listeners
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; }
Contributing
If you have suggestions for improvement, please submit a pull request.
License
AdminYard is released under the MIT License. See LICENSE for details.