Preface

About Twill

Twill is an open source Laravel package that helps developers rapidly create a custom CMS that is beautiful, powerful, and flexible. By standardizing common functions without compromising developer control, Twill makes it easy to deliver a feature-rich admin console that focuses on modern publishing needs.

Twill is an AREA 17 product. It was crafted with the belief that content management should be a creative, productive, and enjoyable experience for both publishers and developers.

Benefits overview

With a vast number of pre-built features and custom-built Vue.js UI components, developers can focus their efforts on the unique aspects of their applications instead of rebuilding standard ones.

Built to get out of your way, Twill offers:

  • No lock-in, create your data models or hook existing ones
  • No front-end assumptions, use it within your Laravel app or headless
  • No bloat, turn off features you don’t need
  • No need to write/adapt HTML for the admin UI
  • No limits, extend as you see fit

Feature list

CRUD modules

  • Enhanced Laravel “resources” models
  • Command line generator and conventions to speed up creating new ones
  • Based on PHP traits and regular Laravel concepts (migrations, models, controllers, form requests, repositories, Blade views)
  • Fully custom forms per content type
  • Slugs management, including the ability to automatically redirect old urls
  • Configurable content listings with searching, filtering, sorting, publishing, featuring, reordering and more
  • Support for all Eloquent ORM relationships (1-1, 1-n, n-n, polymorphic)
  • Content versioning

UI Components

  • Large library of plugged in Vue.js form components with tons of options for maximum flexibility and composition
  • Completely abstracted HTML markup. You’ll never have to deal with Bootstrap HTML again, which means you won’t ever have to maintain frontend related code for your CMS
  • Input, text area, rich text area form fields with option to set SEO optimized limits
  • Configurable WYSIWYG built with Quill.js
  • Inline translated fields with independent publication status (no duplication)
  • Select, multi-select, content type browsers for related content and tags
  • Form repeaters
  • Date and color pickers
  • Flexible content blocks editor (dynamically composable from all form components)
  • Custom content blocks per content type

Media library

  • Media/files library with S3 and imgix integration (3rd party services are swappable)
  • Image selector with smart cropping
  • Ability to set custom image requirements and cropping parameters per content type
  • Multiple crops possible per image for art directed responsive
  • Batch uploading and tagging
  • Metadata editing (alternative text, caption)
  • Multi fields search (filename, alternative text, tags, dimensions…)

Configuration based features

  • User authentication, authorization and management
  • Fully configurable CMS navigation, with three levels of hierarchy and breadcrumbs for limitless content structure
  • Configurable CMS dashboard with quick access links, activity log and Google Analytics integration
  • Configurable CMS global search
  • Intuitive content featuring, using a buckets UI. Put any of your content types in "buckets" to manage any layout of featured content or other concepts like localization

Developer experience

  • Maintain a Laravel application, not a Twill application
  • Support for Laravel 5.3 to 5.6 and will be updated to supports all future versions
  • Support for both MySQL and PostgreSQL databases
  • No conflict with other Laravel packages – keep building with your tools of choice
  • No specific server requirements, if you can deploy a Laravel application, you can deploy Twill
  • Development and production ready toolset (debug bar, inspector, exceptions handler)
  • No data lock in – all Twill content types are proper relational database tables, so it’s easy to move to Twill from other solutions and to expose content created with your Twill CMS to other applications
  • Previewing and side by side comparison of fully rendered frontend site that you’ll get up and running very quickly whatever the way you built your frontend (fully headed Laravel app, hybrid Laravel app with your own custom API endpoints or even full SPA with frameworks like React or Vue)
  • Scales to very large amount of content without performances drawbacks, even on minimal resources servers (for what it’s worth, it’s running perfectly fine on a $5/month VPS, and you can cache frontend pages if you’d like through packages like laravel-response-cache or a CDN like Cloudfront)

Credits

Over the last 15 years, nearly every engineer at AREA 17 has contributed to Twill in some capacity. The current iteration of Twill as an open source initiative was created by:

  • Quentin Renard, lead application engineer
  • Antoine Doury, lead interface engineer
  • Antonin Caudron, interface engineer
  • Martin Rettenbacher, product designer
  • Jesse Golomb, product owner
  • George Eid, product manager

Additional contributors include Laurens van Heems, Fernando Petrelli, Gilbert Moufflet, Mubashar Iqbal, Pablo Barrios, Luis Lavena, and Mike Byrne.

Contribution guide

Bug reports and features submission

To submit an issue or request a feature, please do so on Github.

If you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix.

Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem.

Security vulnerabilities

If you discover a security vulnerability within Twill, please email us at security@twill.io. All security vulnerabilities will be promptly addressed.

Versioning scheme

Twill's versioning scheme maintains the following convention: paradigm.major.minor. Minor releases should never contain breaking changes.

When referencing Twill from your application, you should always use a version constraint such as 1.1.*, since major releases of Twill do include breaking changes.

The VERSION file at the root of the project needs to be updated and a Git tag created to properly release a new version.

Which branch?

All bug fixes should be sent to the latest stable branch (1.1). Bug fixes should never be sent to the master branch unless they fix features that exist only in the upcoming release.

Minor features that are fully backwards compatible with the current Twill release may be sent to the latest stable branch.

Major new features should always be sent to the master branch, which contains the upcoming Twill release.

Please send coherent history — make sure each individual commit in your pull request is meaningful. If you had to make a lot of intermediate commits while developing, please squash them before submitting.

Coding style

Licensing

Software

The Twill software is licensed under the Apache 2.0 license.

User interface

The Twill UI, including but not limited to images, icons, patterns, and derivatives thereof are licensed under the Creative Commons Attribution 4.0 International License.

Attribution

By using the Twill UI, you agree that any application which incorporates it shall prominently display the message “Made with Twill” in a legible manner in the footer of the admin console. This message must open a link to Twill.io when clicked or touched. For permission to remove the attribution, contact us at hello@twill.io.

Getting started

Installation

Server requirements

Twill being a package for your Laravel application, it shares its server requirements, which are satisfied by both Laravel Homestead and Valet.

Installing Twill

composer require area17/twill

Add Twill Install service provider in config/app.php (before Application Service Providers):

<?php

'providers' => [
    ...
    A17\Twill\TwillInstallServiceProvider::class,
];

Setup your .env file:

# APP_URL without scheme so that the package can resolve admin.APP_URL automatically
# Your computer should be able to resolve both APP_URL and admin.APP_URL
# For example, with a vagrant vm you should add to your /etc/hosts file:
# 192.168.10.10 APP_URL
# 192.168.10.10 admin.APP_URL
APP_URL=domain.local 

# Optionnaly, you can specify the admin url yourself 
#ADMIN_APP_URL=manage.domain.local
# as well as a path if you want to show the admin on the same domain as your app
#ADMIN_APP_PATH=admin

# When running on 2 different subdomains (which is the default configuration), you might want to share cookies 
# between both so that CMS users can access drafts on the frontend
#SESSION_DOMAIN=.domain.local

# If you use S3 uploads, you'll need those credentials
#AWS_KEY=
#AWS_SECRET=
#AWS_BUCKET=
#AWS_USE_HTTPS=true/false

# If you use Imgix, you'll need a source url
#IMGIX_SOURCE_HOST=source.imgix.net
#IMGIX_USE_SIGNED_URLS=true/false
#IMGIX_USE_HTTPS=true/false

# Delete uploaded files when deleting from media library UI
#MEDIA_LIBRARY_CASCADE_DELETE=true

# Needed only if you use a map form field
#GOOGLE_MAPS_API_KEY=

Run the install command

php artisan twill:install

Run the setup command (it will migrate your database schema so run it where your database is accessible, ie. inside Vagrant if you are using Laravel Homestead)

php artisan twill:setup

Setup your list of available languages for translated fields in config/translatable.php (without nested locales).

<?php

return [
    'locales' => [
        'en',
        'fr',
    ],

Use a single locale code (inside the same array) if you're not using model translations in your project.

Next, let's setup the CMS UI toolset composed of NPM scripts.

Add the following npm scripts to your project's package.json:

"scripts": {
  "cms-build": "npm run cms-copy-blocks && cd vendor/area17/twill && npm ci && npm run prod && cp -R public/ ${INIT_CWD}/public",
  "cms-copy-blocks": "npm run cms-clean-blocks && mkdir -p resources/assets/js/blocks/ && mkdir -p vendor/area17/twill/frontend/js/components/blocks/customs/ && cp -R resources/assets/js/blocks/ vendor/area17/twill/frontend/js/components/blocks/customs/",
  "cms-clean-blocks": "rm -rf vendor/area17/twill/frontend/js/components/blocks/customs/*"
}

Finally, add the following to your project .gitignore if you don't want to put CMS compiled assets in Git:

public/assets/admin
public/mix-manifest.json
public/hot

You'll want to run npm run cms-build to start working locally as well on a production server.

If you are working on blocks, or contributing to this project, and would like to use Hot Module Reloading to propagate your changes when recompiling blocks or contributing to Twill itself, use npm run cms-dev. You'll need to install the following dev dependencies to your project's package.json:

"devDependencies": {
    "concurrently": "^3.5.1",
    "watch": "^1.0.2"
}

And the following npm scripts:

"scripts": {
  "cms-dev": "mkdir -p vendor/area17/twill/public && npm run cms-copy-blocks && concurrently \"cd vendor/area17/twill && npm ci && npm run hot\" \"npm run cms-watch\" && npm run cms-clean-blocks",
  "cms-watch": "concurrently \"watch 'npm run cms-hot' vendor/area17/twill/public --wait=2 --interval=0.1\" \"npm run cms-watch-blocks\"",
  "cms-hot": "cd vendor/area17/twill && cp -R public/ ${INIT_CWD}/public",
  "cms-watch-blocks": "watch 'npm run cms-copy-blocks' resources/assets/js/blocks --wait=2 --interval=0.1"
}

That's about it!

Configuration

Options

You can choose to use the default configuration, which allows you to get started without having to modify anything. This configuration is:

  • users management
  • media library on S3 with Imgix
  • file library on S3

The only thing you have to do with the default configuration to get setup is enabling the necessary environment variables in your .env file.

You can override any of these configurations values independendtly from the empty config/twill.php file that was published in your app when you ran the twill:install command.

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Application Namespace
    |--------------------------------------------------------------------------
    |
    | This value is the namespace of your application.
    |
     */
    'namespace' => 'App',

    /*
    |--------------------------------------------------------------------------
    | Application Admin URL
    |--------------------------------------------------------------------------
    |
    | This value is the URL of your admin application.
    |
     */
    'admin_app_url' => env('ADMIN_APP_URL', 'admin.' . env('APP_URL')),
    'admin_app_path' => env('ADMIN_APP_PATH', ''),

    /*
    |--------------------------------------------------------------------------
    | Twill Enabled Features
    |--------------------------------------------------------------------------
    |
    | This array allows you to enable/disable the Twill default features.
    |
     */
    'enabled' => [
        'users-management' => true,
        'media-library' => true,
        'file-library' => true,
        'block-editor' => true,
        'buckets' => false,
        'users-image' => false,
        'site-link' => false,
        'settings' => false,
        'google-login' => false,
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill Auth configuration
    |--------------------------------------------------------------------------
    |
    | Right now this only allows you to redefine the
    | default login redirect path.
    |
     */
    'auth_login_redirect_path' => '/',

    /*
    |--------------------------------------------------------------------------
    | Twill Media Library configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to provide the package with your configuration
    | for the media library disk, endpoint type and others options depending
    | on your endpoint type.
    |
    | Supported endpoint types: 'local' and 's3'.
    | Set cascade_delete to true to delete files on the storage too when
    | deleting from the media library.
    | If using the 'local' endpoint type, define a 'local_path' to store files.
    | Supported image service: 'A17\Twill\Services\MediaLibrary\Imgix'
    |
     */
    'media_library' => [
        'disk' => 'libraries',
        'endpoint_type' => env('MEDIA_LIBRARY_ENDPOINT_TYPE', 's3'),
        'cascade_delete' => env('MEDIA_LIBRARY_CASCADE_DELETE', false),
        'local_path' => env('MEDIA_LIBRARY_LOCAL_PATH'),
        'image_service' => env('MEDIA_LIBRARY_IMAGE_SERVICE', 'A17\Twill\Services\MediaLibrary\Imgix'),
        'acl' => env('MEDIA_LIBRARY_ACL', 'private'),
        'filesize_limit' => env('MEDIA_LIBRARY_FILESIZE_LIMIT', 50),
        'allowed_extensions' => ['svg', 'jpg', 'gif', 'png', 'jpeg'],
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill Imgix configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to provide the package with your configuration
    | for the Imgix image service.
    |
     */
    'imgix' => [
        'source_host' => env('IMGIX_SOURCE_HOST'),
        'use_https' => env('IMGIX_USE_HTTPS', true),
        'use_signed_urls' => env('IMGIX_USE_SIGNED_URLS', false),
        'sign_key' => env('IMGIX_SIGN_KEY'),
        'default_params' => [
            'fm' => 'jpg',
            'q' => '80',
            'auto' => 'compress,format',
            'fit' => 'min',
        ],
        'lqip_default_params' => [
            'fm' => 'gif',
            'auto' => 'compress',
            'blur' => 100,
            'dpr' => 1,
        ],
        'social_default_params' => [
            'fm' => 'jpg',
            'w' => 900,
            'h' => 470,
            'fit' => 'crop',
            'crop' => 'entropy',
        ],
        'cms_default_params' => [
            'q' => 60,
            'dpr' => 1,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill File Library configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to provide the package with your configuration
    | for the file library disk, endpoint type and others options depending
    | on your endpoint type.
    |
    | Supported endpoint types: 'local' and 's3'.
    | Set cascade_delete to true to delete files on the storage too when
    | deleting from the file library.
    | If using the 'local' endpoint type, define a 'local_path' to store files.
    |
     */
    'file_library' => [
      'disk' => 'libraries',
      'endpoint_type' => env('FILE_LIBRARY_ENDPOINT_TYPE', 's3'),
      'cascade_delete' => env('FILE_LIBRARY_CASCADE_DELETE', false),
      'local_path' => env('FILE_LIBRARY_LOCAL_PATH'),
      'file_service' => env('FILE_LIBRARY_FILE_SERVICE', 'A17\Twill\Services\FileLibrary\Disk'),
      'acl' => env('FILE_LIBRARY_ACL', 'public-read'),
      'filesize_limit' => env('FILE_LIBRARY_FILESIZE_LIMIT', 50),
      'allowed_extensions' => [],
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill Block Editor configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to provide the package with your configuration
    | for the Block Editor form field.
    |
     */
    'block_editor' => [
        'block_single_layout' => 'site.layouts.block',
        'block_views_path' => 'site.blocks',
        'block_views_mappings' => [],
        'block_preview_render_childs' => true,
        'blocks' => [
            'blocktype' => [
                'title' => 'Block title',
                'icon' => 'text/image/grid/quote/...',
                'component' => 'a17-block-blocktype',
            ],
            ...
        ],
        'repeaters' => [
            'repeatertype' => [
                'title' => 'Repeater title',
                'trigger' => 'Add button label',
                'component' => 'a17-block-repeatertype',
                'max' => 10,
            ],
        ],
        'crops' => [
            'crop_role' => [
                'crop_name' => [
                    [
                        'name' => 'ratio_name',
                        'ratio' => 16/9,
                    ],
                    ...
                ],
                ...
            ],
        ],
        'browser_route_prefixes' => [
            'pluralModuleName' => 'routePrefix',
            ...
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill SEO configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to provide the package with some SEO configuration
    | for the frontend site controller helper and image service.
    |
     */
    'seo' => [
        'site_title' => config('app.name'),
        'site_desc' => config('app.description'),
        'image_default_id' => env('SEO_IMAGE_DEFAULT_ID'),
        'image_local_fallback' => env('SEO_IMAGE_LOCAL_FALLBACK'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill Developer configuration
    |--------------------------------------------------------------------------
    |
    | This array allows you to enable/disable debug tool and configurations.
    |
     */
    'debug' => [
        'use_whoops' => env('DEBUG_USE_WHOOPS', true),
        'whoops_path_guest' => env('WHOOPS_GUEST_PATH'),
        'whoops_path_host' => env('WHOOPS_HOST_PATH'),
        'use_inspector' => env('DEBUG_USE_INSPECTOR', false),
        'debug_bar_in_fe' => env('DEBUG_BAR_IN_FE', false),
    ],

    /*
    |--------------------------------------------------------------------------
    | Twill Frontend assets configuration
    |--------------------------------------------------------------------------
    |
    | This allows you to setup frontend helpers related settings.
    |
    |
     */
    'frontend' => [
        'rev_manifest_path' => public_path('dist/rev-manifest.json'),
        'dev_assets_path' => '/dist',
        'dist_assets_path' => '/dist',
        'svg_sprites_path' => 'sprites.svg', // relative to dev/dist assets paths
        'svg_sprites_use_hash_only' => true,
        'views_path' => 'site',
        'home_route_name' => 'home',
    ],
];

This file manages the navigation of your admin area. Using the CMS UI, the package provides 2 levels of navigation: global and primaryy. This file simply contains a nested array description of your navigation.

Each entry is defined by multiple options. The simplest entry has a title and a route option which is a Laravel route name. A global entry can define a primary_navigation array that will contains more entries.

Two other options are provided that are really useful in conjunction with the CRUD modules you'll create in your application: module and can. module is a boolean to indicate if the entry is routing to a module route. By default it will link to the index route of the module you used as your entry key. can allows you to display/hide navigation links depending on the current user and permission name you specify.

Example:

<?php

return [
    'work' => [
        'title' => 'Work',
        'route' => 'admin.work.projects.index',
        'primary_navigation' => [
            'projects' => [
                'title' => 'Projects',
                'module' => true,
            ],
            'clients' => [
                'title' => 'Clients',
                'module' => true,
            ],
            'industries' => [
                'title' => 'Industries',
                'module' => true,
            ],
            'studios' => [
                'title' => 'Studios',
                'module' => true,
            ],
        ],
    ],
];

To make it work properly and to get active states automatically, you should structure your routes in the same way using for example here:

<?php

Route::group(['prefix' => 'work'], function () {
    Route::module('projects');
    Route::module('clients');
    Route::module('industries');
    Route::module('studios');
});

CRUD modules

Architecture concepts

In progress

CLI Generator

You can generate all the files needed for a new CRUD using the generator:

php artisan twill:module yourPluralModuleName

The command has a couple of options :

  • --hasBlocks (-B),
  • --hasTranslation (-T),
  • --hasSlug (-S),
  • --hasMedias (-M),
  • --hasFiles (-F),
  • --hasPosition (-P)
  • --hasRevisions(-R).

It will generate a migration file, a model, a repository, a controller, a form request object and a form view.

Start by filling in the migration and models.

Add Route::module('yourPluralModuleName}'); to your admin routes file.

Setup a new CMS menu item in config/twill-navigation.php.

Setup your index options and columns in your controller.

Setup your form fields in resources/views/admin/moduleName/form.blade.php.

Enjoy.

Routes

A router macro is available to create module routes quicker:

<?php

Route::module('yourModulePluralName');

// You can add an array of only/except action names as a second parameter
// By default, the following routes are created : 'reorder', 'publish', 'browser', 'bucket', 'feature', 'restore', 'bulkFeature', 'bulkPublish', 'bulkDelete', 'bulkRestore'
Route::module('yourModulePluralName', ['except' => ['reorder', 'feature', 'bucket', 'browser']])

// You can add an array of only/except action names for the resource controller as a third parameter
// By default, the following routes are created : 'index', 'store', 'show', 'edit', 'update', 'destroy'
Route::module('yourModulePluralName', [], ['only' => ['index', 'edit', 'store', 'destroy']])

// The last optional parameter disable the resource controller actions on the module
Route::module('yourPluralModuleName', [], [], false)

Migrations

Migrations are regular Laravel migrations. A few helpers are available to create the default fields any CRUD module will use:

<?php

// main table, holds all non translated fields
Schema::create('table_name_plural', function (Blueprint $table) {
    createDefaultTableFields($table)
    // will add the following inscructions to your migration file
    // $table->increments('id');
    // $table->softDeletes();
    // $table->timestamps();
    // $table->boolean('published');
});

// translation table, holds translated fields
Schema::create('table_name_singular_translations', function (Blueprint $table) {
    createDefaultTranslationsTableFields($table, 'tableNameSingular')
    // will add the following inscructions to your migration file
    // createDefaultTableFields($table);
    // $table->string('locale', 6)->index();
    // $table->boolean('active');
    // $table->integer("{$tableNameSingular}_id")->unsigned();
    // $table->foreign("{$tableNameSingular}_id", "fk_{$tableNameSingular}_translations_{$tableNameSingular}_id")->references('id')->on($table)->onDelete('CASCADE');
    // $table->unique(["{$tableNameSingular}_id", 'locale']);
});

// slugs table, holds slugs history
Schema::create('table_name_singular_slugs', function (Blueprint $table) {
    createDefaultSlugsTableFields($table, 'tableNameSingular')
    // will add the following inscructions to your migration file
    // createDefaultTableFields($table);
    // $table->string('slug');
    // $table->string('locale', 6)->index();
    // $table->boolean('active');
    // $table->integer("{$tableNameSingular}_id")->unsigned();
    // $table->foreign("{$tableNameSingular}_id", "fk_{$tableNameSingular}_translations_{$tableNameSingular}_id")->references('id')->on($table)->onDelete('CASCADE')->onUpdate('NO ACTION');
});

// revisions table, holds revision history
Schema::create('table_name_singular_revisions', function (Blueprint $table) {
    createDefaultRevisionTableFields($table, 'tableNameSingular');
    // will add the following inscructions to your migration file
    // $table->increments('id');
    // $table->timestamps();
    // $table->json('payload');
    // $table->integer("{$tableNameSingular}_id")->unsigned()->index();
    // $table->integer('user_id')->unsigned()->nullable();
    // $table->foreign("{$tableNameSingular}_id")->references('id')->on("{$tableNamePlural}")->onDelete('cascade');
    // $table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});

// related content table, holds many to many association between 2 tables
Schema::create('table_name_singular1_table_name_singular2', function (Blueprint $table) {
    createDefaultRelationshipTableFields($table, $table1NameSingular, $table2NameSingular)
    // will add the following inscructions to your migration file 
    // $table->integer("{$table1NameSingular}_id")->unsigned();
    // $table->foreign("{$table1NameSingular}_id")->references('id')->on($table1NamePlural)->onDelete('cascade');
    // $table->integer("{$table2NameSingular}_id")->unsigned();
    // $table->foreign("{$table2NameSingular}_id")->references('id')->on($table2NamePlural)->onDelete('cascade');
    // $table->index(["{$table2NameSingular}_id", "{$table1NameSingular}_id"]);
});

A few CRUD controllers require that your model have a field in the database with a specific name: published, publish_start_date, publish_end_date, public, and position, so stick with those column names if you are going to use publication status, timeframe and reorderable listings.

Models

Set your fillables to prevent mass-assignement. Very important as we use request()->all() in the module controller.

For fields that should always be saved as null in the database when not sent by the form, use the nullable array.

For fields that should always be saved to false in the database when not sent by the form, use the checkboxes array. The published field is a good example.

Depending on the features you need on your model, include the availables traits and configure their respective options:

  • HasPosition: implement the A17\Twill\Models\Behaviors\Sortable interface and add a position field to your fillables.

  • HasTranslation: add translated fields in the translatedAttributes array and in the fillable array of the generated translatable model in App/Models/Translations (always keep the active and locale fields).

  • HasSlug: specify the field(s) that is going to be used to create the slug in the slugAttributes array

  • HasMedias: add the mediasParams configuration array:

<?php

public $mediasParams = [
    'cover' => [ // role name
        'default' => [ // crop name
            [
                'name' => 'default', // ratio name, same as crop name if single
                'ratio' => 16 / 9, // ratio as a fraction or number
            ],
        ],
        'mobile' => [
            [
                'name' => 'landscape', // ratio name, multiple allowed
                'ratio' => 16 / 9, 
            ],
            [
                'name' => 'portrait', // ratio name, multiple allowed
                'ratio' => 3 / 4,
            ],
        ],
    ],
    '...' => [ // another role
        ... // with crops
    ]
];
  • HasFiles: add the filesParams configuration array
<?php

public $filesParams = ['file_role', ...]; // a list of file roles
  • HasRevisions: no options

Controllers

<?php

    protected $moduleName = 'yourModuleName';
    
    /*
     * Options of the index view
     */
    protected $indexOptions = [
        'create' => true,
        'edit' => true,
        'publish' => true,
        'bulkPublish' => true,
        'feature' => false,
        'bulkFeature' => false,
        'restore' => true,
        'bulkRestore' => true,
        'delete' => true,
        'bulkDelete' => true,
        'reorder' => false,
        'permalink' => true,
        'bulkEdit' => true,
        'editInModal' => false,
    ];

    /*
     * Key of the index column to use as title/name/anythingelse column
     * This will be the first column in the listing and will have a link to the form
     */
    protected $titleColumnKey = 'title';
    
    /*
     * Available columns of the index view
     */
    protected $indexColumns = [
        'image' => [
            'thumb' => true, // image column
            'variant' => [
                'role' => 'cover',
                'crop' => 'default',
            ],
        ],
        'title' => [ // field column
            'title' => 'Title',
            'field' => 'title',
        ],
        'subtitle' => [
            'title' => 'Subtitle',
            'field' => 'subtitle',
            'sort' => true, // column is sortable
            'visible' => false, // will be available from the columns settings dropdown
        ],
        'relationName' => [ // relation column
            'title' => 'Relation name',
            'sort' => true,
            'relationship' => 'relationName',
            'field' => 'relationFieldToDisplay'
        ],
        'presenterMethodField' => [ // presenter column
            'title' => 'Field title',
            'field' => 'presenterMethod',
            'present' => true,
        ]
    ];

    /*
     * Columns of the browser view for this module when browsed from another module
     * using a browser form field
     */
    protected $browserColumns = [
        'title' => [
            'title' => 'Title',
            'field' => 'title',
        ],
    ];

    /*
     * Relations to eager load for the index view
     */
    protected $indexWith = [];

    /*
     * Relations to eager load for the form view
     * Add relationship used in multiselect and resource form fields
     */
    protected $formWith = [];

    /*
     * Relation count to eager load for the form view
     */
    protected $formWithCount = [];

    /*
     * Filters mapping ('filterName' => 'filterColumn')
     * You can associate items list to filters by having a filterNameList key in the indexData array
     * For example, 'category' => 'category_id' and 'categoryList' => app(CategoryRepository::class)->listAll()
     */
    protected $filters = [];

    /*
     * Add anything you would like to have available in your module's index view
     */
    protected function indexData($request)
    {
        return [];
    }

    /*
     * Add anything you would like to have available in your module's form view
     * For example, relationship lists for multiselect form fields
     */
    protected function formData($request)
    {
        return [];
    }

    // Optional, if the automatic way is not working for you (default is ucfirst(str_singular($moduleName)))
    protected $modelName = 'model';

    // Optional, to specify a different feature field name than the default 'featured'
    protected $featureField = 'featured';

    // Optional, specify number of items per page in the listing view (-1 to disable pagination)
    protected $perPage = 20;

    // Optional, specify the default listing order
    protected $defaultOrders = ['title' => 'asc'];

    // Optional, specify the default listing filters
    protected $defaultFilters = ['search' => 'title|search'];

You can also override all actions and internal functions, checkout the ModuleController source in A17\Twill\Http\Controllers\Admin\ModuleController.

Form Requests

Classic Laravel 5 form request validation.

You can choose to use different rules for creation and update by implementing the following 2 functions instead of the classic rules one :

<?php

public function rulesForCreate()
{
    return [];
}

public function rulesForUpdate()
{
    return [];
}

There is also an helper to define rules for translated fields without having to deal with each locales:

<?php

$this->rulesForTranslatedFields([
 // regular rules
], [
  // translated fields rules with just the field name like regular rules
]);

There is also an helper to define validation messages for translated fields:

<?php

$this->messagesForTranslatedFields([
 // regular messages
], [
  // translated fields messages
]);

Repositories

Depending on the model feature, include one or multiple of those traits: HandleTranslations, HandleSlugs, HandleMedias, HandleFiles, HandleRevisions, HandleBlocks, HandleRepeaters, HandleTags.

Repositories allows you to modify the default behavior of your models by providing some entry points in the form of methods that you might implement:

  • for filtering:
<?php

// implement the filter method
public function filter($query, array $scopes = []) {

    // and use the following helpers

    // add a where like clause
    $this->addLikeFilterScope($query, $scopes, 'field_in_scope');

    // add orWhereHas clauses
    $this->searchIn($query, $scopes, 'field_in_scope', ['field1', 'field2', 'field3']);

    // add a whereHas clause
    $this->addRelationFilterScope($query, $scopes, 'field_in_scope', 'relationName');

    // or just go manually with the $query object
    if (isset($scopes['field_in_scope'])) {
      $query->orWhereHas('relationName', function ($query) use ($scopes) {
          $query->where('field', 'like', '%' . $scopes['field_in_scope'] . '%');
      });
    }

    // don't forget to call the parent filter function
    return parent::filter($query, $scopes);
}
  • for custom ordering:
<?php

// implement the order method
public function order($query, array $orders = []) {
    // don't forget to call the parent order function
    return parent::order($query, $orders);
}
  • for custom form fieds
<?php

// implement the getFormFields method
public function getFormFields($object) {
    // don't forget to call the parent getFormFields function
    $fields = parent::getFormFields($object);

    // get fields for a browser
    $fields['browsers']['relationName'] = $this->getFormFieldsForBrowser($object, 'relationName');

    // get fields for a repeater
    $fields = $this->getFormFieldsForRepeater($object, 'relationName');

    // return fields
    return $fields
}

  • for custom field preparation before create action
<?php

// implement the prepareFieldsBeforeCreate method
public function prepareFieldsBeforeCreate($fields) {
    // don't forget to call the parent prepareFieldsBeforeCreate function
    return parent::prepareFieldsBeforeCreate($fields);
}

  • for custom field preparation before save action
<?php

// implement the prepareFieldsBeforeSave method
public function prepareFieldsBeforeSave($object, $fields) {
    // don't forget to call the parent prepareFieldsBeforeSave function
    return parent:: prepareFieldsBeforeSave($object, $fields);
}

  • for after save actions (like attaching a relationship)
<?php

// implement the afterSave method
public function afterSave($object, $fields) {
    // for exemple, to sync a many to many relationship
    $this->updateMultiSelect($object, $fields, 'relationName');
    
    // which will simply run the following for you
    $object->relationName()->sync($fields['relationName'] ?? []);
    
    // or, to save a oneToMany relationship
    $this->updateOneToMany($object, $fields, 'relationName', 'formFieldName', 'relationAttribute')
    
    // or, to save a belongToMany relationship used with the browser field
    $this->updateBrowser($object, $fields, 'relationName');
    
    // or, to save a hasMany relationship used with the repeater field
    $this->updateRepeater($object, $fields, 'relationName');
    
    // or, to save a belongToMany relationship used with the repeater field
    $this->updateRepeaterMany($object, $fields, 'relationName', false);
    
    parent::afterSave($object, $fields);
}

  • for hydrating the model for preview of revisions
<?php

// implement the hydrate method
public function hydrate($object, $fields)
{
    // for exemple, to hydrate a belongToMany relationship used with the browser field
    $this->hydrateBrowser($object, $fields, 'relationName');

    // or a multiselect
    $this->hydrateMultiSelect($object, $fields, 'relationName');

    // or a repeater
    $this->hydrateRepeater($object, $fields, 'relationName');

    return parent::hydrate($object, $fields);
}

Form fields

Wrap them into the following in your module form view (resources/views/admin/moduleName/form.blade.php):

@extends('twill::layouts.form')
@section('contentFields')
    @formField('...', [...])
    ...
@stop

The idea of the contentFields section is to contain the most important fields and the block editor as the last field.

If you have attributes, relationships, extra images, file attachments or repeaters, you'll want to add a fieldsets section after the contentFields section and use the a17-fieldset Vue component to create new ones like in the following example:

@extends('twill::layouts.form', [
    'additionalFieldsets' => [
        ['fieldset' => 'attributes', 'label' => 'Attributes'],
    ]
])

@section('contentFields')
    @formField('...', [...])
    ...
@stop

@section('fieldsets')
    <a17-fieldset title="Attributes" id="attributes">
        @formField('...', [...])
        ...
    </a17-fieldset>
@stop

The additional fieldsets array passed to the form layout will display a sticky navigation of your fieldset on scroll. You can also rename the content section by passing a contentFieldsetLabel property to the layout.

Input

screenshot

@formField('input', [
    'name' => 'subtitle',
    'label' => 'Subtitle',
    'maxlength' => 100,
    'required' => true,
    'note' => 'Hint message goes here',
    'placeholder' => 'Placeholder goes here',
])

@formField('input', [
    'translated' => true,
    'name' => 'subtitle_translated',
    'label' => 'Subtitle (translated)',
    'maxlength' => 250,
    'required' => true,
    'note' => 'Hint message goes here',
    'placeholder' => 'Placeholder goes here',
    'type' => 'textarea',
    'rows' => 3
])

WYSIWYG

screenshot

@formField('wysiwyg', [
    'name' => 'case_study',
    'label' => 'Case study text',
    'toolbarOptions' => ['list-ordered', 'list-unordered'],
    'placeholder' => 'Case study text',
    'maxlength' => 200,
    'note' => 'Hint message',
])

@formField('wysiwyg', [
    'name' => 'case_study',
    'label' => 'Case study text',
    'toolbarOptions' => [ [ 'header' => [1, 2, false] ], 'list-ordered', 'list-unordered', [ 'indent' => '-1'], [ 'indent' => '+1' ] ],
    'placeholder' => 'Case study text',
    'maxlength' => 200,
    'editSource' => true,
    'note' => 'Hint message',
])

Medias

screenshot

@formField('medias', [
    'name' => 'cover',
    'label' => 'Cover image',
    'note' => 'Minimum image width 1300px'
])

@formField('medias', [
    'name' => 'slideshow',
    'label' => 'Slideshow',
    'max' => 5,
    'note' => 'Minimum image width: 1500px'
])

Datepicker

screenshot

@formField('date_picker', [
    'name' => 'event_date',
    'label' => 'Event date',
    'minDate' => '2017-09-10 12:00',
    'maxDate' => '2017-12-10 12:00'
])

Select

screenshot

@formField('select', [
    'name' => 'office',
    'label' => 'Office',
    'placeholder' => 'Select an office',
    'options' => [
        [
            'value' => 1,
            'label' => 'New York'
        ],
        [
            'value' => 2,
            'label' => 'London'
        ],
        [
            'value' => 3,
            'label' => 'Berlin'
        ]
    ]
])

Select unpacked

screenshot

@formField('select', [
    'name' => 'discipline',
    'label' => 'Discipline',
    'unpack' => true,
    'options' => [
        [
            'value' => 'arts',
            'label' => 'Arts & Culture'
        ],
        [
            'value' => 'finance',
            'label' => 'Banking & Finance'
        ],
        [
            'value' => 'civic',
            'label' => 'Civic & Public'
        ],
        [
            'value' => 'design',
            'label' => 'Design & Architecture'
        ],
        [
            'value' => 'education',
            'label' => 'Education'
        ],
        [
            'value' => 'entertainment',
            'label' => 'Entertainment'
        ],
    ]
])

Multi select

screenshot

@formField('multi_select', [
    'name' => 'sectors',
    'label' => 'Sectors',
    'options' => [
        [
            'value' => 'arts',
            'label' => 'Arts & Culture'
        ],
        [
            'value' => 'finance',
            'label' => 'Banking & Finance'
        ],
        [
            'value' => 'civic',
            'label' => 'Civic & Public'
        ],
        [
            'value' => 'design',
            'label' => 'Design & Architecture'
        ],
        [
            'value' => 'education',
            'label' => 'Education'
        ]
    ]
])

@formField('multi_select', [
    'name' => 'sectors_bis',
    'label' => 'Sectors bis',
    'min' => 1,
    'max' => 2,
    'options' => [
        [
            'value' => 'arts',
            'label' => 'Arts & Culture'
        ],
        [
            'value' => 'finance',
            'label' => 'Banking & Finance'
        ],
        [
            'value' => 'civic',
            'label' => 'Civic & Public'
        ],
        [
            'value' => 'design',
            'label' => 'Design & Architecture'
        ],
        [
            'value' => 'education',
            'label' => 'Education'
        ],
        [
            'value' => 'entertainment',
            'label' => 'Entertainment'
        ],
    ]
])

Block editor

screenshot

@formField('block_editor', [
    'blocks' => ['title', 'quote', 'text', 'image', 'grid', 'test', 'publications', 'news']
])

Repeater

screenshot

<a17-fieldset title="Videos" id="videos" :open="true">
    @formField('repeater', ['type' => 'video'])
</a17-fieldset>

Browser

screenshot

<a17-fieldset title="Related" id="related" :open="true">
    @formField('browser', [
        'label' => 'Publications',
        'max' => 4,
        'name' => 'publications',
        'endpoint' => 'http://admin.cms-sandbox.dev.a17.io/content/posts/browser'
    ])
</a17-fieldset>

Files

screenshot

@formField('files', [
    'name' => 'single_file',
    'label' => 'Single file',
    'note' => 'Add one file (per language)'
])

@formField('files', [
    'name' => 'single_file_no_translate',
    'label' => 'Single file (no translate)',
    'note' => 'Add one file',
    'noTranslate' => true,
])

@formField('files', [
    'name' => 'files',
    'label' => 'Files',
    'noTranslate' => true,
    'max' => 4,
])

Map

screenshot

@formField('map', [
    'name' => 'location',
    'label' => 'Location',
    'showMap' => false,
])

Color

screenshot

@formField('color', [
    'name' => 'main-color',
    'label' => 'Main color'
])

Single checkbox

@formField('checkbox', [
    'name' => 'featured',
    'label' => 'Featured'
])

Multiple checkboxes (multi select as checkboxes)

@formField('checkboxes', [
    'name' => 'sectors',
    'label' => 'Sectors',
    'note' => '3 sectors max & at least 1 sector',
    'min' => 1,
    'max' => 3,
    'inline' => true/false
    'options' => [
        [
            'value' => 'arts',
            'label' => 'Arts & Culture'
        ],
        [
            'value' => 'finance',
            'label' => 'Banking & Finance'
        ],
        [
            'value' => 'civic',
            'label' => 'Civic & Public'
        ],
    ]
])

Radios

@formField('radios', [
    'name' => 'discipline',
    'label' => 'Discipline',
    'default' => 'civic',
    'inline' => true/false,
    'options' => [
        [
            'value' => 'arts',
            'label' => 'Arts & Culture'
        ],
        [
            'value' => 'finance',
            'label' => 'Banking & Finance'
        ],
        [
            'value' => 'civic',
            'label' => 'Civic & Public'
        ],
    ]
])

Media library

screenshot

Storage provider

The media and files libraries currently support S3 and local storage. Head over to the twill configuration file to setup your storage disk and configurations. Also check out the S3 direct upload section of this documentation to setup your IAM users and bucket if you want to use S3 as a storage provider.

Image rendering service

This package currently ship with only one rendering service, Imgix. It is very simple to implement another one like Cloudinary or even a local service like Glide or Croppa. You would have to implement the ImageServiceInterface and modify your twill configuration value media_library.image_service with your implementation class. Here are the methods you would have to implement:

<?php

public function getUrl($id, array $params = []);
public function getUrlWithCrop($id, array $crop_params, array $params = []);
public function getUrlWithFocalCrop($id, array $cropParams, $width, $height, array $params = []);
public function getLQIPUrl($id, array $params = []);
public function getSocialUrl($id, array $params = []);
public function getCmsUrl($id, array $params = []);
public function getRawUrl($id);
public function getDimensions($id);
public function getSocialFallbackUrl();
public function getTransparentFallbackUrl();

$crop_params will be an array with the following keys: crop_x, crop_y, crop_w and crop_y. If the service you are implementing doesn't support focal point cropping, you can call the getUrlWithCrop from your implementation.

Role & crop params

Each of the data models in your application can have different images roles and crop.

For exemple, roles for a People model could be profile and cover. This allow you display different images for your data modal in the design, depending on the current screen.

Crops are complementary or can be used on their own with a single role to define multiple cropping ratios on the same image.

For example, your Person cover image could have a square crop for mobile screen, but could use a 16/9 crop on larger screen. Those values are editable at your convenience for each model, even if there are already some crop created in the CMS.

The only thing you have to do to make it work is to compose your model and repository with the appropriate traits, respectively HasMedias and HandleMedias, setup your $mediaParams configuration and use the medias form partial in your form view (more info in the CRUD section).

When it comes to using those data model images in the frontend site, there are a few methods on the HasMedias trait that will help you to retrieve them for each of your layouts:

<?php

/**
 * Returns the url of the associated image for $roleName and $cropName.
 * Optionally add params compatible with the current image service in use like w or h.
 * Optionally indicate that you can provide a fallback so that this method will return null
 * instead of the fallback image.
 * Optionally indicate that you are displaying this image in the CMS views.
 * Optionally provide a $media object if you already retrieved one to prevent more SQL requests.
 */
$model->image($roleName, $cropName[, array $params, $has_fallback, $cms, $media])

/**
 * Returns an array of images URLs assiociated with $roleName and $cropName with appended $params.
 * Use this in conjunction with a media form field with the with_multiple and max option.
 */
$model->images($roleName, $cropName[, array $params])

/**
 * Returns the image for $roleName and $cropName with default social image params and $params appended
 */
$model->socialImage($roleName, $cropName[, array $params, $has_fallback])

/**
 * Returns the lqip base64 encoded string from the database for $roleName and $cropName.
 * Use this in conjunction with the RefreshLQIP Artisan command.
 */
$model->lowQualityImagePlaceholder($roleName, $cropName[, array $params, $has_fallback])

/**
 * Returns the image for $roleName and $cropName with default CMS image params and $params appended.
 */
$model->cmsImage($roleName, $cropName[, array $params, $has_fallback])

/**
 * Returns the alt text of the image associated with $roleName.
 */
$model->imageAltText($roleName)

/**
 * Returns the caption of the image associated with $roleName.
 */
$model->imageCaption($roleName)

/**
 * Returns the image object associated with $roleName.
 */
$model->imageObject($roleName)

File library

The file library is much simpler but also work with S3 and local storage. To associate files to your model, use the HasFiles and HandleFiles traits, the $filesParams configuration and the files form partial.

When it comes to using those data model files in the frontend site, there are a few methods on the HasFiles trait that will help you to retrieve direct URLs:

<?php

/**
 * Returns the url of the associated file for $roleName.
 * Optionally indicate which locale of the file if your site has multiple languages.
 * Optionally provide a $file object if you already retrieved one to prevent more SQL requests.
 */
$model->file($roleName[, $locale, $file])

/**
 * Returns an array of files URLs assiociated with $roleName.
 * Use this in conjunction with a files form field with the with_multiple and max option.
 */
$model->filesList($roleName[, $locale])

/**
 * Returns the file object associated with $roleName.
 */
$model->fileObject($roleName)

S3 direct upload

Create a IAM user for full access to the bucket and use its credentials in your .env file. You can use the following IAM permission:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::YOUR_BUCKER_IDENTIFIER/*",
                "arn:aws:s3:::YOUR_BUCKER_IDENTIFIER"
            ]
        }
    ]
}

Create a IAM user for Imgix (or any other similar service) with only read-only access to your bucket and use its credentials to create an S3 source. You can use the following IAM permission:

{
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket",
                "s3:GetBucketLocation"
            ],
            "Resource": [
                "arn:aws:s3:::YOUR_BUCKER_IDENTIFIER/*",
                "arn:aws:s3:::YOUR_BUCKER_IDENTIFIER"
            ]
        }
    ]
}

For improved security, modify the bucket CORS configuration to accept uploads request from your admin domain only:

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>http(s)://YOUR_ADMIN_DOMAIN</AllowedOrigin>
        <AllowedMethod>POST</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <AllowedMethod>DELETE</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <ExposeHeader>ETag</ExposeHeader>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration>

Block editor

Adding blocks

The block editor form field lets you add content freely to your module. The blocks can be easy added and rearranged. Once a block is created, it can be used/added to any module by adding the corresponding traits.

In order to add a block editor you need to add the block_editor field to your module form. e.g.:

@extends('twill::layouts.form')

@section('contentFields')
    @formField('input', [
        'name' => 'description',
        'label' => 'Description',
    ])
...
    @formField('block_editor')
@stop

By adding the @formField('block_editor') you've enabled all the available blocks. To scope the blocks that will be displayed you can add a second parameter with the blocks key. e.g.:

@formField('block_editor', [
    'blocks' => ['quote', 'image']
])

The blocks that can be added need to be defined under the views/admin/blocks folder. The blocks can be defined exactly like a regular form. e.g.:

filename: admin/blocks/quote.blade.php

@formField('input', [
    'name' => 'quote',
    'type' => 'textarea',
    'label' => 'Quote text',
    'maxlength' => 250,
    'rows' => 4
])

Once the form is created an artisan task needs to be run to generate the Vue component for this block.

php artisan twill:blocks

Example output:

$ php artisan twill:blocks
Starting to scan block views directory...
Block Quote generated successfully
All blocks have been generated!
$

The task will generate a file inside the folder resources/assets/js/blocks/. Do not ignore those files in Git.

filename: resources/assets/js/blocks/BlockQuote.vue

<template>
    <div class="block__body">
        <a17-textfield label="Quote text" :name="fieldName('quote')" type="textarea" :maxlength="250" :rows="4" in-store="value" ></a17-textfield>
    </div>
</template>

<script>
  import BlockMixin from '@/mixins/block'

  export default {
    mixins: [BlockMixin]
  }
</script>

With that the block is ready to be used on the form, it just needs to be enabled in the CMS configuration. For it a block_editor key is required and inside you can define the list of blocks available in your project.

filename: config/twill.php

    'block_editor' => [
        'blocks' => [
            ...
            'quote' => [
                'title' => 'Quote',
                'icon' => 'text',
                'component' => 'a17-block-quote',
            ],
            ..
        ]
    ]

Please note the naming convention. If the block added is quote then the component should be prefixed with a17-block-. If you added a block like my_awesome_block then you need to make sure that keep the same name as key and the component name with the prefix. e.g.:

    'block_editor' => [
        'blocks' => [
            ...
            'my_awesome_block' => [
                'title' => 'Title for my awesome block',
                'icon' => 'text',
                'component' => 'a17-block-my_awesome_block',
            ],
            ..
        ]

After having the blocks added and the configuration set it is required to have the traits added inside your module(Laravel Model). Add the corresponding traits to your model and repository, respectively HasBlocks and HandleBlocks.

filename: app/Models/Article.php

<?php

namespace App\Models;

use A17\Twill\Models\Behaviors\HasBlocks;
use A17\Twill\Models\Model;

class Article extends Model
{
    use HasBlocks;

    ...
}

filename: app/Repositories/ArticleRepository.php

<?php

namespace App\Repositories;

use A17\Twill\Repositories\Behaviors\HandleBlocks;
use A17\Twill\Repositories\ModuleRepository;
use App\Models\Article;

class ArticleRepository extends ModuleRepository
{
    use HandleBlocks;

    ...
}

Common Errors

  • Make sure your project have the blocks table migration. If not, you can find the create_blocks_table migration in Twill's source in migrations.

  • Not running the twill:blocks task.

  • Not adding the block to the configuration.

  • Not using the same name of the block inside the configuration.

Adding repeater blocks

Lets say that it is requested to have an Accordion on Articles, where each item should have a Header and a Description. This accordion can be moved around along with the rest of the blocks. On the Article (module) form we have:

filename: views/admin/articles/form.blade.php

@extends('twill::layouts.form')

@section('contentFields')
    @formField('input', [
        'name' => 'description',
        'label' => 'Description',
    ])
...
    @formField('block_editor')
@stop

  • Inside the container block file, add a repeater form field:

    filename: admin/blocks/accordion.blade.php

  @formField('repeater', ['type' => 'accordion_item'])
  • Add it on the config/twill.php
    'block_editor' => [
        'blocks' => [
            ...
            'accordion' => [
                'title' => 'Accordion',
                'icon' => 'text',
                'component' => 'a17-block-accordion',
            ],
            ..
        ]
    ]
  • Add the item block, the one that will be reapeated inside the container block filename: admin/blocks/accordion_item.blade.php
  @formField('input', [
      'name' => 'header',
      'label' => 'Header'
  ])

  @formField('input', [
      'type' => 'textarea',
      'name' => 'description',
      'label' => 'Description',
      'rows' => 4
  ])
  • Add it on the config/twill.php on the repeaters section
    'block_editor' => [
        'blocks' => [
            ...
            'accordion' => [
                'title' => 'Accordion',
                'icon' => 'text',
                'component' => 'a17-block-accordion',
            ],
            ..
        ],
        'repeaters' => [
            ...
            'accordion_item' => [
                'title' => 'Accordion',
                'trigger' => 'Add accordion',
                'component' => 'a17-block-accordion_item',
                'max' => 10,
            ],
            ...
        ]
    ]

Common errors:

  • If you add the container block to the repeaters section inside the config, it won't work, e.g.:
        'repeaters' => [
            ...
            'accordion' => [
                'title' => 'Accordion',
                'trigger' => 'Add accordion',
                'component' => 'a17-block-accordion',
                'max' => 10,
            ],
            ...
        ]
  • If you use a different name for the block inside the repeaters section, neither. e. g.:
        'repeaters' => [
            ...
            'accordion-item' => [
                'title' => 'Accordion',
                'trigger' => 'Add accordion',
                'component' => 'a17-block-accordion_item',
                'max' => 10,
            ],
            ...
        ]
  • Not adding the item block to the repeaters section.

Adding browser fields

If you are requested to enable the possibility to add a related model, then the browser fields are the match. If you have an Article that can have related products.

On the Article(entity) form we have:

filename: views/admin/articles/form.blade.php

@extends('twill::layouts.form')

@section('contentFields')
    @formField('input', [
        'name' => 'description',
        'label' => 'Description',
    ])
...
    @formField('block_editor')
@stop

  • Add the block editors that will handle the Browser Field filename: views/admin/blocks/products.blade.php
    @formField('browser', [
        'routePrefix' => 'content',
        'moduleName' => 'products',
        'name' => 'products',
        'label' => 'Products',
        'max' => 10
    ])
  • Define the block in the configuration like any other block in the config/twill.php.
    'blocks' => [
        ...
        'products' => [
            'title' => 'Products',
            'icon' => 'text',
            'component' => 'a17-block-products',
        ],
  • After that, it is required to add the Route Prefixes. e.g.:
    'block_editor' => [
        'blocks' => [
            ...
            'product' => [
                'title' => 'Product',
                'icon' => 'text',
                'component' => 'a17-block-products',
            ],
            ...
        ],
        'repeaters' => [
                ...
        ],
        'browser_route_prefixes' => [
            'products' => 'content',
        ],
    ]

Rendering blocks

As long as you have access to a model instance that uses the HasBlocks trait in a view, you can call the renderBlocks helper on it to render the list of blocks that were created from the CMS. By default, this function will loop over all the blocks and their child blocks and render a Blade view located in resources/views/site/blocks with the same name as the block key you specified in your Twill configuration and module form.

In the frontend templates, you can call the renderBlocks helper like this:

{!! $item->renderBlocks() !!}

If you want to render child blocks (when using repeaters) inside the parent block, you can do the following:

{!! $work->renderBlocks(false) !!}

If you need to swap out a block view for a specific module (let’s say you used the same block in 2 modules of the CMS but need different rendering), you can do the following:

{!! $work->renderBlocks(true, [
  'block-type' => 'view.path',
  'block-type-2' => 'another.view.path'
]) !!}

In those Blade view, you will have access to a $blockvariable with a couple of helper function available to retrieve the block content:

{{ $block->input('inputNameYouSpecifiedInTheBlockFormField') }}
{{ $block->translatedinput('inputNameYouSpecifiedInATranslatedBlockFormField') }}

If the block has a media field, you can refer to the Media Library documentation below to learn about the HasMedias trait helpers. To give an exemple:

{{ $block->image('mediaFieldName', 'cropNameFromBlocksConfig') }}
{{ $block->images('mediaFieldName', 'cropNameFromBlocksConfig')}}

Other CMS features

User management

Authentication and authorization are provided by default in Laravel. This package simply leverages what Laravel provides and configure the views for you. By default, users can login at /login and can also reset their password through that same screen. New users have to reset their password before they can gain access to the admin application. By using the twill configuration file, you can change the default redirect path (auth_login_redirect_path) and send users to anywhere in your application following login.

Roles

The package currently provides three different roles:

  • view only
  • publisher
  • admin

Permissions

Default permissions are as follows. To learn how permissions can be modified or extended, see the next section.

View only users are able to:

  • login
  • view CRUD listings
  • filter CRUD listings
  • view media/file library
  • download original files from the media/file library
  • edit their own profile

Publishers have the same permissions as view only users plus:

  • full CRUD permissions
  • publish
  • sort
  • feature
  • upload new images/files to the media/file library

Admin user have the same permissions as publisher users plus:

  • full permissions on users

There is also a super admin user that can impersonate other users at /users/impersonate/{id}. The super admin can be a useful tool for testing features with different user roles without having to logout/login manually, as well as for debugging issues reported by specific users. You can stop impersonating by going to /users/impersonate/stop.

Extending user roles and permissions

You can create or modify new permissions for existing roles by using the Gate façade in your AuthServiceProvider. The can middleware, provided by default in Laravel, is very easy to use, either through route definition or controller constructor.

You should follow the Laravel documentation regarding authorization. It's pretty good. Also if you would like to bring administration of roles and permissions to the admin application, spatie/laravel-permission would probably be your best friend.

Version control

In progress

Previewing

In progress

Featuring content

Twill's `buckets allow you to provide admins with featured content management screens. You can add multiple pages of buckets anywhere you'd like in your CMS navigation and, in each page, multiple buckets with differents rules and accepted modules. In the following example, we will assume that our application has a Guide model and that we want to feature guides on the homepage of our site. Our site's homepage has multiple zones for featured guides: a primary zone, that shows only one featured guide, and a secondary zone, that shows guides in a carousel of maximum 10 items.

First, you will need to enable the buckets feature. In config/twill.php:

'enabled' => [
    'buckets' => true,
],

Then, define your buckets configuration:

'buckets' => [
    'homepage' => [
        'name' => 'Home',
        'buckets' => [
            'home_primary_feature' => [
                'name' => 'Home primary feature',
                'bucketables' => [
                    [
                        'module' => 'guides',
                        'name' => 'Guides',
                        'scopes' => ['published' => true],
                    ],
                ],
                'max_items' => 1,
            ],
            'home_secondary_features' => [
                'name' => 'Home secondary features',
                'bucketables' => [
                    [
                        'module' => 'guides',
                        'name' => 'Guides',
                        'scopes' => ['published' => true],
                    ],
                ],
                'max_items' => 10,
            ],
        ],
    ],
],

You can allow mixing modules in a single bucket by adding more modules to the bucketables array. Each bucketableshould have its model morph map defined because features are stored in a polymorphic table. In your AppServiceProvider, you can do it like the following:

use Illuminate\Database\Eloquent\Relations\Relation;
...
public function boot()
{
    Relation::morphMap([
        'guides' => 'App\Models\Guide',
    ]);
}

Finally, add a link to your buckets page in your CMS navigation:

return [
   'featured' => [
       'title' => 'Features',
       'route' => 'admin.featured.homepage',
       'primary_navigation' => [
           'homepage' => [
               'title' => 'Homepage',
               'route' => 'admin.featured.homepage',
           ],
       ],
   ],
   ...
];

By default, the buckets page (in our example, only homepage) will live at under the /featured prefix. But you might need to split your buckets page between sections of your CMS. For example if you want to have the homepage bucket page of our example under the /pages prefix in your navigation, you can use another configuration property:

'bucketsRoutes' => [
    'homepage' => 'pages'
]

Dashboard

In progress

In progress

Settings pages

In progress

Frontend

Static templates

Frontenders, you might often be the first users of this package in new Laravel apps when starting to work on static templates.

Creating Blade files in views/templates will make them directly accessible at admin.domain.dev/templates/file-name.

Feel free to use all Blade features, extend a parent layout and cut out your views in partials, this will help a lot during integration.

Frontend assets should live in the public/dist folder along with a rev-manifest.json for compiled assets in production. Using the A17 FE Boilerplate should handle that for you.

Use the revAsset('asset.{css|js}) helper in your templates to get assets URLs in any environment.

Use the icon('icon-name', []) helper to display an icon from the SVG sprite. The second parameter is an array of options. It currently understands title, role and css_class.

Using as a headless platform

In progress

Resources

Other useful packages