Download the PHP package tobento/app-crud without Composer

On this page you can find all versions of the php package tobento/app-crud. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package app-crud

App Crud

A simple app CRUD.

Table of Contents

Getting Started

Add the latest version of the app crud project running this command.

Requirements

Documentation

App

Check out the App Skeleton if you are using the skeleton.

You may also check out the App to learn more about the app in general.

Crud Boot

The crud boot does the following:

Crud Controller

With the CRUD controller you can create pages such as index, create, update, delete or custom pages for resources implementing the Repository Interface.

Create Controller

To create a crud controller simply extend the and specify a resource name with the constant.

Next, declare your repository class on the constructor method. You may use the Storage Repository to easily create a repository.

Finally, configure the fields, actions and filters.

Entity Mapping

You may overwrite the method to map your repository entity object to the CRUD entity.

Entity Id Name

You may change the entity id name used as the id name of the entity.

Entity Actions

You may overwrite the following methods to customize the read and write repository actions.

findEntities

storeEntity

updateEntity

deleteEntity

Configure Fields

Use the method to configure any fields using the Build in Fields or creating your custom fields.

Configure Actions

Use the method to configure any actions using the Custom Actions.

Configure Filters

Use the method to configure any filters using the Build in Filters or creating your custom filters.

Route Controller

After creating the configured actions:

Routing via Crud Boot

Manually Routing

You may define the routes manually, if you need even more control:

You may check out the App Http - Routing for more information on routing.

You may check out the Routing Service for more information on routing resources.

Route Permissions

You may install the App User and use the Verify Route Permission Middleware to protect your crud routes from users without the defined permissions.

Fields

Build in Fields

Checkboxes Field

The checkboxes field displays a list of checkboxes using the specified options.

Options

Use the method to define the options to choose:

Empty Option

Use the method to change the empty option value needed when no option is selected:

Selected Options

You may use the method to define the selected value(s):

Attributes

You may set HTML attributes assigned to each input element using the method:

Validation

Data are being validated using the defined options. You may define additional rules though:

You may check out the Validate Field section for more detail.

File Field

The file field enables you to upload a single file using the FileSource Field. In addition, you can define any fields relating to the file source such an alternative text for images. The data will be stored in JSON format.

Fields

Translatable

Use the method if you want to have translatable files.

FileSource Field

Use the method to customize the FileSource Field.

Storing filenames

You may use the method to store the filenames to a certain file field.

Files Field

The files field enables you to upload multiple files using the FileSource Field. In addition, you can define any fields relating to the file source such an alternative text for images. The data will be stored in JSON format.

Fields

File Field

Use the method to customize the File Field.

Number Of Files

Use the method to set the and/or allowed files.

FileSource Field

The file source field enables you to upload a single file, storing the file path such as . If you want to store other data such as the storage name or an alternative text for images, consider using the File Field.

File Storage

Use the method to change the storage name where to store the file. By default the storage is used. Make sure your defined storage is outside the webroot such as the default configured uploads storage.

Make sure the storage is configured in the file.

Check out the App File Storage to learn more about file storages in general.

Folder

Use the method to define a folder path.

Validation

Use the method to define the allowed file extensions. By default, only , , and are allowed.

If you need more control validating files, use the method:

Check out the Media Upload Validator section to learn more about the validator.

File Writer

If you need more control about writing files to the storage, use the method:

Check out the Media File Writer section to learn more about the file writer.

Images

By default, images get displayed on the index and edit page using the Media Picture Feature. You may change its default definition or setting it to null to disable it.

Image Editor

You may define an image editor template using the method. Once defined, images can be edited using the Image Editor Feature by clicking the edit button on the file. Make sure the feature is installed and the template is defined.

You will need to define an event listener in the file to deleted generated images once an image is edited:

Picture Editor

You may define a picture using the method. Once defined, images can be edited using the Picture Editor Feature by clicking the edit picture button on the file. Make sure the feature is installed and the template is defined.

Display Messages

Use the method to define the messages to be displayed.

Html Field

The html field may be used if you want to set HTML content.

Content

Use the method to set the HTML. Make sure any html you set is being properly escaped if needed:

In addition, you may pass a callable being resolved by autowiring:

Defaults

Items Field

The items field displays a collection of items allowing you to add, edit and delete items.

Options Field

The options field displays searchable options to choose from using the defined repository. If you have only a few options, you may consider using the Checkboxes Field instead.

Repository (required)

Use the method to define the repository implementing the Repository Interface:

toOption (required)

Use the method to create options from the repository items:

Or using option methods:

Selected Options

You may use the method to define the selected value(s):

Placeholder

Use the method to define a placeholder text for the serach input element:

Empty Option

Use the method to change the empty option value needed when no option is selected:

Validation

Data are being validated using the repository to query the options. You may define additional rules though:

You may check out the Validate Field section for more detail.

PrimaryId Field

The primary id field will not be displayed on the , and view. In additon, it cannot be edited at all.

Radios Field

The radios field displays a list of radios using the specified options.

Options

Use the method to define the options to choose:

Selected Option

You may use the method to define the selected value:

Attributes

You may set HTML attributes assigned to each radio element using the method:

Display Inline

Use the method for the radio options to be displayed inline:

Validation

Data are being validated using the defined options. You may define additional rules though:

You may check out the Validate Field section for more detail.

Select Field

The select field will be rendered as a HTML element.

Options

Use the method to define the options to select:

You may define options as groups:

Empty Option

Use the method to define an empty option which will not be saved when selected:

Selected Options

You may use the method to define the selected value(s):

Attributes

You may set additional HTML select attributes using the method:

Validation

Data are being validated using the defined options. You may define additional rules though:

You may check out the Validate Field section for more detail.

Slug Field

The slug field generates slugs based on the provided input. For example, the input of is usually something like .

fromField

You may define a field to generate the slug from when no input from the slug field is provided.

Slugifier

You may use the slugifier method to define a custom slugifier. By default, the slugifier named is used but as the slugifier not exists, the from the will be used.

You can define a custom slugifier in the file.

You may check out the App Slugging bundle to learn more about it in general.

Unique Slugs

By default, generated slugs will be saved in the Slug Repository or deleted from when changed. The slug repository is added to the Slugs by default in the file which enables you to use Slug Matches on routes.

In addition, the default slugifier used has the Prevent Dublicate Modifier applied so that slugs will be generated uniquely.

You may disable unique slugs by using the method if you want to implement a custom strategy:

Attributes

You may set additional HTML input attributes using the method:

Example With Readonly

Text Field

The text field will be rendered as an element of the type as default. Use this field for any other input type as well.

Type

You may set another HTML input type as the default type using the method:

Value

You may set a value using the method.

Default Value

You may set a default value using the method:

Attributes

You may set additional HTML input attributes using the method:

Textarea Field

The textarea field will be rendered as an element.

Attributes

You may set additional HTML textarea attributes using the method:

TextEditor Field

This field creates a JavaScript-based WYSIWYG editor using the JS Editor.

editorConfig

This method allows you to pass a PHP array of the configuration options to set passed to the js editor attribute:

Security

This field does NOT sanitize the input in any way. You should sanitize the input or output using the App HTML Sanitizer, so you can safely render the value without escaping.

If you use a Repository Storage With Columns, you may use the or method on the column to clean the value:

Sure, you may sanitize the html depending on the context such as in your view file:

Value Field

The value field may be used if you want to set the value directly on the field. The value will never be set by any user input.

Value

Use the method to set the value for the field:

Defaults

Different Fields Per Action

Option 1

Using the fields , and methods:

Option 2

Using the action name:

Option 3

Using the action method:

Validate Field

The field validation is using the Validation Service to validate the field.

applyValidationAttributes

By default, HTML validation attributes gets created based on your rules and applied for the fields select. You may disable it using the method if you want to add validation attributes by yourself.

Translatable Field

If you use a Repository Storage With Columns, make sure you use a translatable column:

Supported Locales

All locales are supported as defined in the Language Config.

Unstorable Field

When you set a field as unstorable using the method, the field gets not saved.

Readonly And Disabled Field

You may set a field as readonly or disabled which will be automatically an unstorable field.

Field Grouping

Use the method to group fields:

Field Texts


By default, the required text will be set automatically if you have any required validation rules set. But you may specify a custom text:


By default, the optional text will be set automatically if you do not have any required validation rules set. But you may specify a custom text:


You may specify an info text:

Field Resolving

You may use the method to set any field parameters from a resolved value or just for specific actions.

Custom Field Action

You may customize exisiting field actions or add custom actions using the method:

Actions

Build in Actions

Index Action

Configure Buttons

The default buttons are named , , , , and .

Check out the Buttons section for more information.

Create Action

Configure Buttons

The default buttons are named , , close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Create::new(title: 'New product')
            ->removeButton('create', 'edit'),
    ];
}

Check out the Buttons section for more information.

Store Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Store::new(),
    ];
}

Edit Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Edit::new(title: 'Edit product'),

        // or using the entity:
        Action\Edit::new(fn (EntityInterface $entity): string => 'Edit Product: '.$entity->get('sku'))

            // you may set a custom view:
            ->view('custom/crud/edit')
    ];
}

Configure Buttons

The default buttons are named cancel, save, close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Edit::new(title: 'Edit product')
            ->removeButton('copy', 'new'),
    ];
}

Update Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Update::new(),
    ];
}

Unupdatable Entities

Sometimes, it may be useful to prevent certain entities from being updatabed by using the unupdatable method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Update::new()
            // by entity ids using an array:
            ->unupdatable([12, 13])

            // or using a closure:
            ->unupdatable(fn (EntityInterface $entity): bool => in_array($entity->get('sku'), ['foo', 'bar'])),

        // In addition, you may not display the edit button for those entities:
        Action\Index::new('Products')
            ->displayButtonIf('edit', fn (EntityInterface $entity): bool => !in_array($entity->get('sku'), ['foo', 'bar']))
    ];
}

Copy Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Copy::new(title: 'Copy product'),

        // or using the entity:
        Action\Copy::new(fn (EntityInterface $entity): string => 'Copy Product: '.$entity->get('sku'))

            // you may set a custom view:
            ->view('custom/crud/copy')
            //->view('crud/create') // is default view
    ];
}

Configure Buttons

The default buttons are named cancel, save, close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Copy::new(title: 'Copy product')
            ->removeButton('copy', 'new'),
    ];
}

Show Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Show::new(title: 'Show product'),

        // or using the entity:
        Action\Show::new(fn (EntityInterface $entity): string => 'Product: '.$entity->get('sku')),

        Action\Show::new(title: 'Show product')
            // you may set a custom view:
            ->view('custom/crud/show')
    ];
}

Configure Buttons

The default buttons are named back.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Show::new(title: 'Show product')
            ->removeButton('back'),
    ];
}

Show JSON Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\ShowJson::new(),
    ];
}

Delete Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Delete::new(),
    ];
}

Undeletable Entities

Sometimes, it may be useful to prevent certain entities from being deleted by using the undeletable method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Delete::new()
            // by entity ids using an array:
            ->undeletable([12, 13])

            // or using a closure:
            ->undeletable(fn (EntityInterface $entity): bool => in_array($entity->get('sku'), ['foo', 'bar'])),

        // In addition, you may not display the delete button for those entities:
        Action\Index::new('Products')
            ->displayButtonIf('delete', fn (EntityInterface $entity): bool => !in_array($entity->get('sku'), ['foo', 'bar']))
    ];
}

Bulk Delete Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\BulkDelete::new(),
    ];
}

Bulk Edit Action

You can make as many bulk edit actions as you want. Make sure the name parameter is unique and only contains a-z letters and hyphens.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\BulkEdit::new(name: 'edit-status', title: 'Edit Status')
            ->field('status'),

        Action\BulkEdit::new(name: 'edit-multiple', title: 'Edit Multiple Fields')
            ->field('fieldname', 'another-fieldname'),
    ];
}

Supported Fields

The following fields support bulk editing:

Buttons

All build-in actions have already specified the buttons for linking to other actions. You may configure the buttons by the following methods.

You may view the action buttons method to see its configuration such as the button names.

Creating Buttons

Available Buttons

$link = Button\Link::new(label: 'Label', group: 'entity');
// renders an <a> element

$button = Button\Button::new(label: 'Label', group: 'entity');
// renders a <button> element

$delete = Button\Delete::new(label: 'Label', group: 'entity');
// renders a <form> element to delete an entity: 

$dropdown = Button\Dropdown::new(label: 'Label', group: 'entity');

Linking Methods

$link = Button\Link::new(label: 'View invoice', group: 'entity')
    // link to an existing action:
    ->linkToAction('viewInvoice')

    // link to a route:
    ->linkToRoute('viewInvoice')

    // link to a route with parameters:
    ->linkToRoute('viewInvoice', [
        'param' => 'value',
    ])

    // link to a route using a closure:
    ->linkToRoute('viewInvoice', function(EntityInterface $entity): array {
        return ['id' => $entity->id()];
    })

    // link to an url:
    ->linkToUrl('https://example.com/invoice')

    // link to an url using a closure:
    ->linkToUrl(function(EntityInterface $entity): string {
        return 'https://example.com/invoice/'.$entity->id();
    });

General Methods

$link = Button\Link::new(label: 'View invoice', group: 'entity')
    // You may define a name:
    ->name('viewInvoice')
    // You modify the label:
    ->label('View invoice')
    // You may define an icon:
    ->icon('invoice')

    // You may set it as primary button:
    ->primary()
    // You may set it as raw button:
    ->raw()

    // You may add an attribute:
    ->attr('data-foo', 'value')
    // You may remove an attribute:
    ->removeAttr('data-foo')

    // You may ask to confirm the action using inline buttons:
    ->askConfirmation()
    // If a text is given, a modal is used:
    ->askConfirmation('Are you sure you want to perform this action.')
    // Or disable it if previous set:
    ->askConfirmation(false)

    // You may use AJAX to perform the action:
    ->ajaxAction()
    // With a success message:
    ->ajaxAction('Action performed successfully.')
    // Or disable it if previous set:
    ->ajaxAction(false);

Adding Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    $button = Button\Link::new(label: 'View invoice', group: 'entity')
        ->name('viewInvoice')
        ->linkToAction('viewInvoice');

    return [
        Action\Index::new(title: 'Products')
            ->addButton($button),
    ];
}

Removing Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->removeButton('create', 'edit'),
    ];
}

Reorder Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->reorderButtons('edit', 'delete'),
    ];
}

Modify Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button\ButtonInterface;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->modifyButton('edit', function(ButtonInterface $button, EntityInterface $entity): void {
                $button
                    ->label('')
                    ->icon('pencil')
                    ->primary(false)
                    ->raw(true);
            }),
    ];
}

Grouping Buttons

You may group buttons using the groupButtons method. If no group button is defined, buttons will be grouped using a dropdown button.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->groupButtons(
                except: ['edit'],
                //only: ['show', 'delete'],

                // You may set a label:
                label: 'More',
                // You may set an icon:
                icon: 'dots',
                // You may set a name:
                name: 'more',
            )

            // or you may define a custom button:
            ->groupButtons(
                only: ['show', 'delete'],
                button: Button\Dropdown::new(label: '', icon: 'dots', group: 'entity')
                    ->name('anotherGroup')
                    ->raw(),
            ),
    ];
}

Display Buttons Conditionally

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->displayButtonIf('viewInvoice', fn (EntityInterface $entity): bool => $entity->get('isPaid'))

            // or with bool:
            ->displayButtonIf('viewInvoice', true),
    ];
}

Confirming Button Action

You may ask to confirm the button action using the confirmButtonAction method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            // asking to confirm the action using inline buttons:
            ->confirmButtonAction('delete')

            // if a text is given, a modal is opened asking to confirm the action:
            ->confirmButtonAction('delete', 'Are you sure you want to delete the product')

            // or using a callable:
            ->confirmButtonAction('delete', function(EntityInterface $entity): string {
                return sprintf('Are you sure you want to delete the product %s', $entity->id());
            })

            // you may disable it if already set by default:
            ->confirmButtonAction('delete', false),
    ];
}

AJAX Button Action

You may use the ajaxButtonAction method to set whether to use AJAX to perform the button action:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            // using ajax:
            ->ajaxButtonAction('delete')

            // using ajax with a success message:
            ->ajaxButtonAction('delete', 'Deleted the product successfully.')

            // or using a callable:
            ->ajaxButtonAction('delete', function(EntityInterface $entity): string {
                return sprintf('Deleted the product %s successfully.', $entity->id());
            })

            // you may disable it if already set by default:
            ->ajaxButtonAction('delete', false),
    ];
}

Set Buttons

Using the setButtons method will overwrite the default buttons. Any configurable buttons methods such as reorderButtons e.g. will be ignored though!

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->setButtons(
                Button\Link::new(label: 'Create New', group: 'global')
                    ->name('create')
                    ->linkToAction('create'),
            ),
    ];
}

Custom Action

You may create a custom action by the following way:

1. Create And Add Button

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    $viewInvoiceBtn = Button\Link::new(label: 'View Invoice', group: 'entity')
        ->name('viewInvoice')
        // link to an action:
        ->linkToAction('viewInvoice')
        // or link to a route:
        ->linkToRoute('products.invoice.view', function(EntityInterface $entity): array {
            return ['id' => $entity->id()];
        });

    return [
        Action\Index::new(title: 'Products')
            ->addButton($viewInvoiceBtn),
        //...
    ];
}

2. Create Action

If your added button links to a custom action using the linkToAction method, you will need to create the corresponding action, otherwise skip this step:

use Tobento\App\Crud\Button\ButtonsInterface;
use Tobento\App\Crud\Button\Buttons;
use Tobento\App\Crud\Button;
use Tobento\App\Crud\Entity\EntityInterface;
use Closure;

final class ViewInvoice extends AbstractAction
{
    public function __construct(
        null|string|Closure $title = null,
    ) {
        $this->title = $title;
        $this->route('{name}.invoice.view', function(EntityInterface $entity): array {
            return ['id' => $entity->id()];
        });
    }

    public static function new(null|string|Closure $title = null): static
    {
        return new static($title);
    }

    public function name(): string
    {
        return 'viewInvoice';
    }

    public function buttons(): ButtonsInterface
    {
        if ($this->buttons instanceof ButtonsInterface) {
            return $this->buttons;
        }

        $this->buttons = new Buttons(
            Button\Link::new(label: $this->trans('Back to index'), group: 'entity')
                ->name('back')
                ->linkToAction('index'),
        );

        return $this->applyButtonsConfig($this->buttons);
    }
}

3. Route your action to the CRUD controller

use Tobento\Service\Routing\RouterInterface;

// After adding boots
$app->booting();

$router = $this->app->get(RouterInterface::class);

$name = App\ProductsController::RESOURCE_NAME;

// needed if you have configured bulk actions:
$router->get($name.'/invoice/{id}', [App\ProductsController::class, 'viewInvoice'])
    ->name($name.'.invoice.view');

Sure, you may route it to a different controller too!

4. Create your method in the routed controller

use Psr\Http\Message\ResponseInterface;
use Tobento\App\Crud\AbstractCrudController;

class ProductsController extends AbstractCrudController
{
    public function viewInvoice(int|string $id): ResponseInterface
    {
        // ...
        return $response;
    }
}

Filters

Build in Filters

Checkboxes Filter

Adds multiple HTML input elements of the type checkbox filtering the checked values.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Checkboxes::new(name: 'colors', field: 'color')
            // specify the options using an array:
            ->options(['blue' => 'Blue', 'red' => 'Red'])

            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllColors()),

            // you may set the default selected values:
            ->selected(['blue', 'red'])

            // you may add attributes applied to all input elements:
            ->attributes(['data-foo' => 'foo'])

            // you may change the comparison:
            ->comparison('in') // = (default)
            // 'in', 'not like', 'contains'

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // display in table at the field:
            ->group('field')

            // you may set a label:
            ->label('Colors')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Checkboxes::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Checkboxes::new(name: 'categories') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])

            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Checkboxes $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (empty($filter->getSelected())) {
                    return;
                }

                $ids = $repo->findProductIdsForCategoryIds(
                    categoryIds: $filter->getSelected(),

                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Clear Button Filter

Filter to display a button for clearing filter(s).

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\ClearButton::new()

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a custom label:
            ->label('Clear all filters')

            // you may set button attributes:
            ->attributes(['data-foo' => 'value']),

        // or clear specific filter by its names:
        Filter\ClearButton::new(
            filters: ['foo', 'bar'],
            name: 'unique-filter-name', // only if multiple clear button filters
        )->label('Clear foo and bar filters'),
    ];
}

Columns Filter

Filter to display only the selected columns.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Columns::new()

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a label:
            ->label('Columns')

            // you may set a description:
            ->description('The columns to display') // is default text

            // you may set a custom view:
            ->view('custom/crud/filter')
    ];
}

Datalist Filter

Filter to display a HTML datalist element.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')
            // specify the options using an array:
            ->options(['foo', 'bar'])

            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllTitles()),

        // set the list attributes on the filter you want the datalist to be displayed:
        Filter\Input::new(name: 'data', field: 'title')
            ->attributes(['list' => 'list-titles']),
    ];
}

Using optionsFromField method

You may use the optionsFromField method to easily retrive options from the specified field. Your repository must be of Storage Repository, otherwise it gets ignored.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\Service\Repository\Storage\StorageRepository;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')
            ->optionsFromField(field: 'title', limit: 50),

            // or specify fromInput parameters, applying a where like query:
            ->optionsFromField(field: 'title', fromInput: 'data', limit: 50),

        // set the list attributes on the filter you want the datalist to be displayed:
        Filter\Input::new(name: 'data', field: 'title')
            ->attributes(['list' => 'list-titles']),
    ];
}

Examples using options with a closure

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\App\Crud\InputInterface;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')

            // $input, $action and $filter parameters will always be available,
            // any other parameters are resolved by autowiring:
            ->options(function(ProductRepository $repo, InputInterface $input, ActionInterface $action, Filter\Datalist $filter): array {
                // You may get the locales from the action:
                $locale = $action->getLocale();
                $locales = $action->getLocales();

                // You may get any fields:
                $field = $action->fields()->get(name: 'name');

                // Be careful with $input values as they may come from user input!
                $value = $input->get('data');

                if (!empty($value) && is_string($value)) {
                    // Example if storage repository by using the underlying storage query builder:
                    return $repo->query()->where('title->en', 'like', $value.'%')->column('title->en')->all();

                    // Example using a custom repository method:
                    return $repo->findAllTitlesFromValue(value: $value, locale: $locale);
                }

                return [];
            }),
    ];
}

Editable Columns Filter

Filter to enable inline editing fields in table columns.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\EditableColumns::new('sku', 'title') // define the editable columns

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a label:
            ->label('Editable Columns')

            // you may set a description:
            ->description('The columns to edit in table.'), // is default text
    ];
}

Supported Fields

The following fields support inline table editing:

Fields Filter

The fields filter displays an select filter on each field in the table column.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        ...Filter\Fields::new()

            // set the fields from the action:
            ->fields($action->fields())

            // display only specific:
            ->only('sku', 'title')

            // or
            ->except('sku', 'title')

            ->toFilters(),
    ];
}

If you want a custom filter for the field just do not display the filter using the except method and add your custom filter with the group field:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        ...Filter\Fields::new()
            ->fields($action->fields())
            ->except('sku')
            ->toFilters(),
        // custom sku filter:
        Filter\Input::new(name: 'sku', field: 'sku')
            ->group('field'),
    ];
}

Fields Sort Order Filter

The fields sort order filter provides the option to order fields up and down.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\FieldsSortOrder::new()

            // display only specific:
            ->only('sku', 'title')

            // or
            ->except('sku', 'title'),
    ];
}

Group Filter

Filter to group other filters.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Group::new(name: 'group-key')
            // display in modal:
            ->group('modal')

            // you may hide the grouped filters:
            ->open(false)

            // you may set a custom label, otherwise name is used:
            ->label('Group Name')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter/group'),

        // Next, assign filters to the group:
        Filter\Columns::new()->group('group-key'),

        // The modal button to open filters:
        Filter\ModalButton::new(),
    ];
}

Input Filter

Adds an HTML input element to filter the field if specified.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'foo', field: 'email')
            // you may change the type:
            ->type('email') // text (default)

            // you may add attributes for the input element:
            ->attributes(['placeholder' => 'value'])

            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // display in table at the field:
            ->group('field')

            // you may set a label:
            ->label('Foo')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Input::new(name: 'options.color', field: 'options->color')
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'colors') // no field defined!
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Input $filter, FiltersInterface $filters): void {
                if (!is_string($filter->getSearchValue())) {
                    return;
                }

                $filter->setWhereParameters(['fieldname' => ['=' => $filter->getSearchValue()]]);
            }),
    ];
}

Locale Filter

Adds a filter to switch the resource locale. Make sure this filter is added as the first filter because other filters may depend on the locales.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        // should be added as the first filter!
        Filter\Locale::new()
            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a label:
            ->label('Resource Locale')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),
    ];
}

Menu Filter

Adds a menu based on the specified items. Records will be filtered by the active menu item. The filter uses the Menu Service to render the menu.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Menu::new(name: 'categories', field: 'category_id')
            // specify the menu items using an array:
            ->items([
                ['id' => 'foo', 'name' => 'Foo', 'parent' => null],
                ['id' => 'bar', 'name' => 'Bar', 'parent' => 'foo'],
            ])

            // or using a closure (parameters are resolved by autowiring):
            ->items(fn(CategoryRepository $repo): array => $repo->findAllMenuItems()),

            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a label:
            ->label('Label')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Menu::new(name: 'categories') // no field defined!
            // specify the menu items using an array:
            ->items([
                ['id' => 'foo', 'name' => 'Foo', 'parent' => null],
                ['id' => 'bar', 'name' => 'Bar', 'parent' => 'foo'],
            ])

            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Menu $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (!is_string($filter->getActive())) {
                    return;
                }

                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getActive(),

                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Modal Button Filter

Filter to display a button for opening the filters in the modal. Will only be displayed if there are filters with the group modal though.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\ModalButton::new()

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in the aside area:
            ->group('aside')

            // you may set a custom label:
            ->label('Filters')

            // you may set button attributes:
            ->attributes(['data-foo' => 'value']),

        // or
        Filter\ModalButton::new(
            name: 'unique-filter-name', // only if multiple clear button filters
        ),
    ];
}

Pagination Filter

Adds pagination for the items.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        // ...
        // must be the last filter added!
        Filter\Pagination::new()

            // you may set a label:
            ->label('Current Page')

            // you may unset the default description:
            ->description('')

            // you may hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // display below table:
            ->group('footer')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // Or:
        Filter\Pagination::new(
            // Specify the default items to show per page:
            show: 100,

            // Specify the max. items (limit) to show per page:
            maxItemsPerPage: 1000, // default 1000
        ),
    ];
}

Pagination Items Per Page Filter

Adds the option to specify how many items to display.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\PaginationItemsPerPage::new()

            // you may unset the default label:
            ->label('')

            // you may set the default description:
            ->description('Per page')

            // you may hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // Or:
        Filter\PaginationItemsPerPage::new(
            // Specify the default items to show per page:
            show: 50, // default 100
        ),

        // required filter, otherwise the above filter is not displayed at all.
        Filter\Pagination::new(),
    ];
}

Radios Filter

Adds multiple HTML input elements of the type radio filtering the selected value.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Radios::new(name: 'status', field: 'status')
            // specify the options using an array:
            ->options(['_none' => 'None', 'pending' => 'Pending', 'paid' => 'Paid'])
            // you may set a value to '_none' which skips filtering if selected!

            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(StatusRepository $repo): array => $repo->findAll()),

            // you may set the default selected value:
            ->selected('_none')

            // you may add attributes applied to all input elements:
            ->attributes(['data-foo' => 'foo'])

            // you may change the comparison:
            ->comparison('=') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // display in table at the field:
            ->group('field')

            // you may set a label:
            ->label('Status')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Radios::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field, allowing multiple selection or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Radios::new(name: 'colors') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])

            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Radios $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (empty($filter->getSelected())) {
                    return;
                }

                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getSelected(),

                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Select Filter

Adds an HTML select element with options to filter the field if specified.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Select::new(name: 'colors', field: 'color')
            // specify the options using an array:
            ->options(['blue' => 'Blue', 'red' => 'Red'])

            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllColors()),

            // you may set the default selected value(s):
            ->selected('blue')
            ->selected(['blue', 'red']) // if multiple

            // you may add attributes for the select element:
            ->attributes(['size' => '3', 'multiple'])

            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'

            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in modal:
            ->group('modal')

            // display in the aside area:
            ->group('aside')

            // display in table at the field:
            ->group('field')

            // you may set a label:
            ->label('Colors')

            // you may set a description:
            ->description('Lorem ipsum')

            // you may set a custom view:
            ->view('custom/crud/filter'),

        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Select::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field, allowing multiple selection or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Select::new(name: 'colors') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])

            // you may add attributes for the select element:
            //->attributes(['size' => '3', 'multiple'])

            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Select $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (!is_string($filter->getSelected())) {
                    return;
                }

                // $filter->getSelected()
                // will return an array if multiple or null if none selected

                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getSelected(),

                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Filter Groups

Available filter groups (if the filter supports it)

Example

use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\App\Crud\Action\ActionInterface;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'foo', field: 'title')

            // display above table (default):
            ->group('header')

            // display below table:
            ->group('footer')

            // display in table at the field:
            ->group('field'),
    ];
}

Filter Limitations

As the Repository Interface and filtering is done using the findAll method you are not able to make complex queries by filters.

$entities = $repository->findAll(
    where: $filters->getWhereParameters(),
    orderBy: $filters->getOrderByParameters(),
    limit: $filters->getLimitParameter(),
);

Security

Keep in mind that the repository is responsibilty to protect against any SQL injections for instance! If your are using the Repository Storage you will be save.

Testing

You may test your crud controllers by using the provided test classes. Just make sure you have installed the App Testing bundle.

Crud Controller Testing

To test your crud controller extend the AbstractCrudTestCase class. Next, use createApp method to create the test app as usual and define your crud controller using the getCrudController method. Finally, write your tests:

use Tobento\App\Crud\Testing\AbstractCrudTestCase;
use Tobento\App\AppInterface;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    public function createApp(): AppInterface
    {
        return require __DIR__.'/../app/app.php';

        // or creating a tmp app:
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Crud\Boot\Crud::class);
        // boot your product crud boot which routes your crud controller:
        $app->boot(ProductCrudBoot::class);
        return $app;
    }

    protected function getCrudController(): string
    {
        return ProductCrudController::class;
    }

    public function testIndexAction()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');

        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(5);
    }
}

Seeding

If you are using Storage Repositories with defined columns, you may use getSeedFactory method to seed entities automatically. Make sure you use a Reset Database Strategy.

use Tobento\App\Crud\Testing\AbstractCrudTestCase;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    use \Tobento\App\Testing\Database\RefreshDatabases;

    // ...

    public function testIndexPageMultipleEntitiesAreDisplayed()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');

        // seed 5 products:
        $this->getSeedFactory()->times(5)->create();

        // seed 3 inactive products:
        $this->getSeedFactory(['active' => false])->times(3)->create();

        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(8);
    }
}

For any other repositories checkout the App Seeding - Repository documentation as to create a seed factory or use the getSeedDefinition method to define a definition for your repository seed factory:

use Tobento\App\Crud\Testing\AbstractCrudTestCase;
use Tobento\Service\Seeder\SeedInterface;
use Tobento\Service\Seeder\Lorem;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    use \Tobento\App\Testing\Database\RefreshDatabases;

    // ...

    protected function getSeedDefinition(): null|\Closure
    {
        return function (SeedInterface $seed): array {
            return [
                'sku' => Lorem::word(number: 1),
                'desc' => Lorem::sentence(number: 2),
            ];
        };
    }

    public function testIndexPageMultipleEntitiesAreDisplayed()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');

        // seed 5 products:
        $this->getSeedFactory()->times(5)->create();

        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(5);
    }
}

Uri Generation

You may use the following methods to generate request uris for the actions:

public function testAnyPage()
{
    $http = $this->fakeHttp();

    // index action uri:
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    // equal to uri: 'products'

    // index action uri with locale:
    $http->request(method: 'GET', uri: $this->generateIndexUri(locale: 'de'));
    // equal to uri: 'de/products'

    // bulk action uri:
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'));
    // equal to uri: 'products/bulk/bulk-edit'

    // bulk action uri with locale:
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit', locale: 'de'));
    // equal to uri: 'de/products/bulk/bulk-edit'    

    // create action uri:
    $http->request(method: 'GET', uri: $this->generateCreateUri());
    // equal to uri: 'products/create'

    // create action uri with locale:
    $http->request(method: 'GET', uri: $this->generateCreateUri(locale: 'de'));
    // equal to uri: 'de/products/create'

    // store action uri:
    $http->request(method: 'POST', uri: $this->generateStoreUri());
    // equal to uri: 'products'

    // store action uri with locale:
    $http->request(method: 'POST', uri: $this->generateStoreUri(locale: 'de'));
    // equal to uri: 'de/products'

    // edit action uri:
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 2));
    // equal to uri: 'products/2/edit'

    // edit action uri with locale:
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2/edit'

    // update action uri:
    $http->request(method: 'PUT|PATCH', uri: $this->generateUpdateUri(id: 2));
    // equal to uri: 'products/2'

    // update action uri with locale:
    $http->request(method: 'PUT|PATCH', uri: $this->generateUpdateUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'

    // copy action uri:
    $http->request(method: 'GET', uri: $this->generateCopyUri(id: 2));
    // equal to uri: 'products/2/copy'

    // copy action uri with locale:
    $http->request(method: 'GET', uri: $this->generateCopyUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2/copy'

    // delete action uri:
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 2));
    // equal to uri: 'products/2'

    // delete action uri with locale:
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'

    // show action uri:
    $http->request(method: 'GET', uri: $this->generateShowUri(id: 2));
    // equal to uri: 'products/2'

    // show action uri with locale:
    $http->request(method: 'GET', uri: $this->generateShowUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'
}

Asserts

The following asserts are avaliable using the default views.

Index Action Asserts

assertCrudIndexEntityCount

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(3)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(3)

        // you may specify a custom error message:
        ->assertCrudIndexEntityCount(3, 'Custom message');
}

assertCrudIndexEntityExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(3)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityExists(entityId: 2)

        // you may specify buttons the entity should have or not:
        ->assertCrudIndexEntityExists(entityId: 2, withButtons: ['edit', 'delete'], withoutButtons: ['show'])

        // you may specify a custom error message when entity does not exists:
        ->assertCrudIndexEntityExists(entityId: 2, message: 'Custom message');
}

assertCrudIndexEntityMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityMissing(entityId: 2)

        // you may specify a custom error message:
        ->assertCrudIndexEntityMissing(entityId: 2, message: 'Custom message');
}

assertCrudIndexHeaderColumnsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexHeaderColumnsExists(columns: ['username', 'actions'])

        // you may specify a custom error message:
        ->assertCrudIndexHeaderColumnsExists(columns: ['username'], message: 'Custom message');
}

assertCrudIndexHeaderColumnsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexHeaderColumnsMissing(columns: ['username', 'actions'])

        // you may specify a custom error message:
        ->assertCrudIndexHeaderColumnsMissing(columns: ['username'], message: 'Custom message');
}

assertCrudIndexFiltersExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexFiltersExists(filters: ['columns'], group: 'header')

        // you may specify a custom error message:
        ->assertCrudIndexFiltersExists(filters: ['pagination_items'], group: 'footer', message: 'Custom message')

        // use the "field" group and field name to check entity field filters:
        ->assertCrudIndexFiltersExists(filters: ['username'], group: 'field');
}

assertCrudIndexFiltersMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexFiltersMissing(filters: ['columns'], group: 'header')

        // you may specify a custom error message:
        ->assertCrudIndexFiltersMissing(filters: ['pagination_items'], group: 'footer', message: 'Custom message')

        // use the "field" group and field name to check entity field filters:
        ->assertCrudIndexFiltersMissing(filters: ['username'], group: 'field');
}

assertCrudIndexButtonsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexButtonsExists(buttons: ['create'], group: 'global')

        // you may specify a custom error message:
        ->assertCrudIndexButtonsExists(buttons: ['create'], group: 'global', message: 'Custom message');
}

assertCrudIndexButtonsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexButtonsMissing(buttons: ['create'], group: 'global')

        // you may specify a custom error message:
        ->assertCrudIndexButtonsMissing(buttons: ['create'], group: 'global', message: 'Custom message');
}

assertCrudIndexBulkActionsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexBulkActionsExists(actions: ['edit-status'])

        // you may specify a custom error message:
        ->assertCrudIndexBulkActionsExists(actions: ['edit-status'], message: 'Custom message');
}

assertCrudIndexBulkActionsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());

    $this->getSeedFactory()->times(1)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexBulkActionsMissing(actions: ['edit-status'])

        // you may specify a custom error message:
        ->assertCrudIndexBulkActionsMissing(actions: ['edit-status'], message: 'Custom message');
}
Form Asserts

You may use the form asserts to test your crud controller copy actions.

assertCrudFormFieldExists

public function testEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 1));
    // or create uri:
    //$http->request(method: 'GET', uri: $this->generateCreateUri());

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'username')

        // Or with more options:
        ->assertCrudFormFieldExists(
            field: 'username',

            // you may specify the label:
            label: 'Username',

            // you may specify the required text:
            requiredText: 'Required because ...',

            // you may specify the optional text:
            optionalText: 'optional',

            // you may specify the info text:
            infoText: 'Some info text',

            // you may specify the error text (validation):
            errorText: 'The title.en is required.',
            // first locale if translatable!

            // you may specify if it is translatable field or not:
            translatable: false,

            // you may specify a custom error message:
            message: 'Custom message',
        );
}

assertCrudFormFieldMissing

public function testEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 1));
    // or create uri:
    //$http->request(method: 'GET', uri: $this->generateCreateUri());

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudFormFieldMissing(field: 'username')

        // you may specify a custom error message:
        ->assertCrudFormFieldMissing(field: 'username', message: 'Custom message');
}
Asserts Selectors

If you customize the view files, make sure you have the following HTML attributes defined, otherwise you may write custom asserts to fit your views!

Index Action Asserts

Form Asserts

Example Tests

Index Filter Action

public function testFilterAction()
{
    $http = $this->fakeHttp();
    $http->request(
        method: 'GET',
        uri: $this->generateIndexUri(),
        query: ['filter' => ['field' => ['type' => 'business']]],
    );

    $this->getSeedFactory(['type' => 'private'])->times(1)->create();
    $this->getSeedFactory(['type' => 'business'])->times(2)->create();

    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(2);
}

Store Action

public function testStoreAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateStoreUri())->body([
        'email' => '[email protected]',
        'smartphone' => '555', // is required
    ]);

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(1);

    $this->assertSame(1, $this->getCrudRepository()->count());
}

Store Action With Validation Errors

public function testStoreActionWithValidationErrors()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateCreateUri());
    $http->request(method: 'POST', uri: $this->generateStoreUri())->body([
        'email' => '[email protected]',
        //'smartphone' => '555', // is required
    ]);

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateCreateUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone is required.');

    $this->assertSame(0, $this->getCrudRepository()->count());
}

Update Action

public function testUpdateAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
        'smartphone' => '555',
    ]);

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(2);

    $this->assertSame('555', $this->getCrudRepository()->findById(1)->smartphone());
}

Update Action With Validation Errors

public function testUpdateActionWithValidationErrors()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateEditUri(id: 1));
    $http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
        'smartphone' => ['invalid'],
    ]);

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateEditUri(id: 1));

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}

Delete Action

public function testDeleteAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 1));

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(1);

    $this->assertSame(1, $this->getCrudRepository()->count());
}

Bulk Edit Action

public function testBulkEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
        'ids' => [2, 3],
        'smartphone' => '555',
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(5);

    $this->assertNotSame('555', $this->getCrudRepository()->findById(1)->smartphone());
    $this->assertSame('555', $this->getCrudRepository()->findById(2)->smartphone());
    $this->assertSame('555', $this->getCrudRepository()->findById(3)->smartphone());
}

Bulk Edit Action With Validation Errors

public function testBulkEditAction()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateIndexUri());
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
        'ids' => [2, 3],
        'smartphone' => ['555'],
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(5)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}

Bulk Delete Action

public function testBulkDeleteAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-delete'))->body([
        'ids' => [2, 3],
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(3);

    $this->assertNotNull($this->getCrudRepository()->findById(1));
    $this->assertNull($this->getCrudRepository()->findById(2));
    $this->assertNull($this->getCrudRepository()->findById(3));
}

Credits


All versions of app-crud with dependencies

PHP Build Version
Package Version
Requires php Version >=8.0
tobento/app-html-sanitizer Version ^1.0
tobento/app-http Version ^1.0.5
tobento/app-media Version ^1.0
tobento/app-message Version ^1.0
tobento/app-language Version ^1.0
tobento/app-slugging Version ^1.0
tobento/app-translation Version ^1.0
tobento/app-validation Version ^1.0
tobento/app-view Version ^1.0
tobento/service-tag Version ^1.0.5
tobento/service-iterable Version ^1.0
tobento/service-collection Version ^1.0
tobento/service-support Version ^1.0.1
tobento/service-autowire Version ^1.0
tobento/service-repository Version ^1.0.1
tobento/service-pagination Version ^1.0
tobento/service-validation Version ^1.0.1
tobento/service-uri Version ^1.0
tobento/service-menu Version ^1.0
tobento/js-editor Version ^1.0
tobento/js-notifier Version ^1.0
tobento/css-modal Version ^1.0
psr/container Version ^2.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package tobento/app-crud contains the following files

Loading the files please wait ....