Skip to content

Lightweight PHP library for building CRUD admin panels

License

Notifications You must be signed in to change notification settings

parpalak/admin-yard

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

75 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AdminYard

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\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.

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:

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

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.

About

Lightweight PHP library for building CRUD admin panels

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages