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
  • Slug 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 block 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 bucket 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.7 and will be updated to support 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 no matter how 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 performance 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:

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.2.*, 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.2). 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 (1.2).

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

Architecture concepts

CRUD modules

A Twill CRUD module is a set of classes and configurations in your Laravel application that enable your publishers to manage a certain type of content. The structure of a CRUD module is completely up to you.

Another way to think of a CRUD module is as a feature rich Laravel resource. In other words (and for the non-Laravel developer), a CRUD module is basically a content type (or post type, as sometimes called by other CMS solutions) with CRUD operations (Create, Read, Update, Delete), as well as custom Twill-provided operations like: Publish, Feature, Tag, Preview, Restore, Restore revision, Reorder or Bulk edit. Using Twill's media library, images and files can be attached to modules records. Also, using Twill's block editor, a rich editing experience of a module's record can be offered to publishers.

In Twill's UI, a CRUD module most often consists of a listing page and a form page or modal. Records created under a module can then be associated with other modules' records to create relationships between your content. Records of your Twill modules and any associations are stored in a traditional relational database schema, following Laravel's migrations and Eloquent model conventions.

Twill's CRUD modules features are enabled using PHP traits you include in your Eloquent models and Twill repositories, as well as various configuration variables, and a bunch of conventions to follow. Further guidance is documented in the CRUD modules section.

A Twill module can be modified however you like – you can include countless types of content fields, and change the organization and structure according to the needs of the module and your product. Setup is simple: you just need to compose a form using all of Twill's available form fields.

While possibilities for composition are endless, we’ve identified four standard content types:

  • Entities: Entities are your primary data models, usually represented on your frontend as listing and detail views. Generally speaking, entity listings are displayed programmatically (e.g., by date, price, etc.) but also can be manually ordered. For example, if you’re building an editorial site, your primary entity might be articles. If you’re building a site to showcase your company’s work, you might have entities for projects, case studies, people, etc. This is the default behavior of a Twill module.

  • Attributes: Attributes are secondary data models most often used to add structured details to an entity (for search, filtering, and/or display). Example attributes include: categories, types, sectors, industries, etc. In a Twill CMS, each attribute needs a listing screen and, within that screen, quick creation and editing ability. As attributes tend to be relatively simple (few content fields, etc), their form screen can often fit within a modal. This modal can be made available from other parts of the CMS rather than only from their own listing screen. In Twill, the editInModal index option of your module's controllers can be used to enable that behavior.

  • Pages: Pages are unstructured data models most often used for static content, such as an About page. Rather than being separated into listing and detail screens, pages are manually organized into parent/child relationships. Combined with the kalnoy/nestedset package, a Twill module can be configured to show and make parent/child relationships manageable on a module's records.

  • Elements: Elements are modules or snippets of content that are added to an entity, page, or screen. Examples include the ability to manage footer text or create a global alert that can be turned on/off, etc. Twill offers developers the ability to quickly create settings sections to manage elements. A Twill module could also be configured to manage any sort of standalone element or content composition. There's nothing wrong with having a database table with a single record if that is what your product require, so you should feel free to create a Twill module to have a custom form for a single record. You can use a Laravel seeder or migration to initialize your admin console with those records.

CRUD listings

One of the benefits of Twill is the ability to fully customize CRUD listing views. At minimum, you’ll want to include the key information for each data record so that publishers can have an at-a-glance view without having to click into a record. You can also set up a default view and give each publisher the ability to customize the columns and the number of records per pagination page.

In certain cases, you may require nested CRUD modules. For example, if you are building a handbook website, the parent CRUD would be the handbooks and then within each handbook there are pages (child CRUD). In this case, the listing will be the parent CRUD and for each record, you’d include a column to access the child CRUDs for each.

CMS navigation

One of the benefits of Twill is the ability to fully customize the navigation as needed to make it easy and intuitive for publishers to navigate through the CMS and perform their regular production duties. Twill has three levels of navigation:

  • Main navigation: we recommend that the main navigation reflects the frontend organization, in that way, it is intuitive for publishers. Additionally, the main navigation includes transversal items such as media library and global settings.

  • Secondary navigation: we recommend that you group all entities, attributes, pages, and possibly buckets (see below) under each main navigation item. For example, if you have a section called “Our work” then the secondary navigation will include: case studies (entity), sectors (attribute), how we work (page), featured (buckets), etc.

  • Tertiary navigation: in certain cases, you will need a third level of navigation, however we recommend that you only use it when absolutely necessary, otherwise content may be too buried. You also have the option to turn the tertiary navigation into a breadcrumb.

Block editor

Central to the Twill experience is the block editor, giving publishers full control of how they construct the content of a record. A block is a composition of form fields made available to publshers in Twill's block editor form field.

Generally speaking, with a standard CMS, all content is managed through fixed forms. While in a Twill CMS some of the content may be fixed (such as title, subtitle, intro, required content, etc.), when using the block editor, the content is constructed by adding and reordering blocks of content. This gives you maximum flexibility to build narrative experiences on the front end.

For example, let’s say you’re building a blog. Your blog post form may require fixed content such as the title, short description, author, etc. But then you can use the block editor for the body of the post, allowing the publisher to add standardized blocks for text, images, quotes, slideshows, videos, related content, embeds, etc. and reorder them as needed.

A block can include any combination of fields, including repeater fields and even data pulled from a third party service. Each block also can contain additional options so that a single block can be displayed according to different variations. This obviates the need to create a new block every time you need a different display of your content, and allows you to match the build of the page to the content, context or design required. For example, you can have a media block that may alternatively include a video or an image, be displayed at small, medium or large, or displayed inline with content or full screen.

To keep page-building as simple as possible, we recommend that you keep blocks to a minimum – ideally no more than 8 blocks, if possible. When adding a new block, consider: is this a unique block or simply block options? Publishers will prefer switching an option using existing content rather than having to create another block and copy and paste.

It is also important that you work with a designer early on to discuss the block strategy and make sure your content works well no matter how your publishers arrange it. Can all the blocks work in any combination or are there restrictions? If the latter, you can create form validations to block publishers from arranging blocks in certain contexts.

Buckets

Buckets are used to feature content. While the name might be boring, your publishers will love them!

The functionality is made up of two parts: an entity navigator and buckets. The entity navigator gives access to the entities, including search and filters. Buckets represent your feature areas. For example, let’s say you have a homepage with main features (such as a hero display pointing your users to 2-3 pages), secondary features (such as a grid of content), and tertiary features. You would create three buckets for each of these feature sections. Then, your publishers can simply drag the desired entity to the bucket they want it featured in.

You can also associate rules for your buckets. For example, let’s say you only want three main features and five secondary features – but unlimited tertiary features. You can add those restrictions and when the publishers try to add more than the limit, they will be informed they need to remove an entity before they can add another.

While buckets are primarily used for featuring, they can also be used for any purpose. For example, if you have a website that has different navigation for different market locations (e.g. USA, Europe, Asia), you can use buckets to manage this.

Environment requirements

Twill is compatible with Laravel 5.3, 5.4, 5.5, 5.6 and 5.7 applications running on PHP 7.

As a dependency to your own application, Twill shares Laravel's server requirements, which are satisfied by both Homestead and Valet during development, and easily deployed to production using Forge and Envoyer or Envoy, as well as any other Laravel compatible server configuration and deployment strategy.

Twill uses Laravel Mix to build the frontend assets of its UI. To ensure reproducible builds, npm scripts provided by Twill use the npm ci command, which is available since npm 5.7.

Twill's database migrations create json columns. Your database should support the json type. Twill has been developed and tested against MySQL (>=5.7) and PostgreSQL(>=9.3).

In summary:

Supported versions Recommended version
PHP >= 7.0 7.2
Laravel >= 5.3 5.7
npm >= 5.7 6.4
MySQL >= 5.7 5.7
PostgreSQL >= 9.3 10

Installation

Composer

Twill is a package for Laravel applications, installable through Composer:

composer require area17/twill:"1.2.*"

Twill will automatically register its service provider if you are using Laravel >=5.5. If you are using Twill with Laravel 5.3 or 5.4, add Twill's service provider in your application's config/app.php file:











 













<?php

'providers' => [
    ...
    Illuminate\Validation\ValidationServiceProvider::class,
    Illuminate\View\ViewServiceProvider::class,

    /*
     * Package Service Providers...
     */
    A17\Twill\TwillServiceProvider::class,
    ...

    /*
     * Application Service Providers...
     */
    App\Providers\AppServiceProvider::class,
    App\Providers\AuthServiceProvider::class,
    // App\Providers\BroadcastServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,
    ...
];

Artisan

Run the install Artisan command:

php artisan twill:install

This command will migrate your database.

Make sure to setup your .env file with your database credentials and to run it where your database is accessible (ie. inside Vagrant if you are using Laravel Homestead).

Twill's install command consists of:

  • creating an admin.php routes files in your application's routes directory. This is where you will declare your own admin console routes.
  • publishing Twill's database migrations to your application's database/migrations directory.
  • migrating your database with those new migrations.
  • publishing Twill's configuration files to your application's config directory.
  • publishing Twill's assets for the admin console UI.
  • prompting you to create a superadmin user.

.env

By default, Twill's admin console is available at admin.domain.test. This is assuming that your .env APP_URL variable does not include a scheme (http/https):

APP_URL=domain.test

In development, make sure that the admin subdomain is available and pointing to your app's public directory.

If you are a Valet user, this is already done for you (any subdomain is linked to the same directory as the linked domain).

If you are a Homestead user, make sure to add the subdomain to your /etc/hosts file too:

# this is an example, use your own IP and domain
192.168.10.10 domain.test
192.168.10.10 admin.domain.test

Optionally, you can specify a custom admin console url using the ADMIN_APP_URL variable. For example:

ADMIN_APP_URL=manage.domain.test

As well as a path using the ADMIN_APP_PATH variable. For example, to have the admin console available on a subdirectory of your app (domain.test/admin):

APP_URL=domain.test
ADMIN_APP_URL=domain.test
ADMIN_APP_PATH=admin

When running on 2 different subdomains (which is the default configuration as seen above), you want to share cookies between both domains so that publishers can access drafts on the frontend. Use the SESSION_DOMAIN variable with your domain, prefixed by a dot, like in the following example:

SESSION_DOMAIN=.domain.test

Accessing the admin console

At this point, you should be able to login at admin.domain.test, manage.domain.test or domain.test/admin depending on your environment configuration. You should be presented with a dashboard with an empty activities list, a link to open Twill's media library and a dropdown to manage users, your own account and logout.

Setting up the media library

From there, you might want to configure Twill's media library's storage provider and its rendering service. By default, Twill is configured to store uploads on AWS S3 and to render images via imgix. Provide the following .env variables to get up and running:

S3_KEY=S3_KEY
S3_SECRET=S3_SECRET
S3_BUCKET=bucket-name

IMGIX_SOURCE_HOST=source.imgix.net

If you are not ready to use those third party services yet, can't use them, or have very limited image rendering needs, Twill also provides a local storage driver as well as a locale image rendering service powered by Glide. The following .env variables should get you up and running:

MEDIA_LIBRARY_ENDPOINT_TYPE=local
MEDIA_LIBRARY_IMAGE_SERVICE=A17\Twill\Services\MediaLibrary\Glide

See the media library's configuration documentation for more information.

npm

Once you create custom blocks for your admin console, Twill's assets needs to be recompiled to include your generated Vue components. In order to do that, add the following npm scripts to your project's package.json:

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

Build Twill's admin console UI assets using:

npm run twill-build

TIP

On Windows, depending on your configuration, you might want to add the --script-shell bash option when running npm commands. Read more here.

If you don't want to store Twill's compiled assets in Git, add the following to your project .gitignore :

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

If you are working on adding/modifying blocks for your application, or contributing to Twill itself, and would like to use Hot Module Reloading to propagate changes when recompiling blocks or modifying Twill, add and 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": {
  "twill-dev": "mkdir -p vendor/area17/twill/public && npm run twill-copy-blocks && concurrently \"cd vendor/area17/twill && npm ci && npm run hot\" \"npm run twill-watch\" && npm run twill-clean-blocks",
  "twill-watch": "concurrently \"watch 'npm run twill-hot' vendor/area17/twill/public --wait=2 --interval=0.1\" \"npm run twill-watch-blocks\"",
  "twill-hot": "cd vendor/area17/twill && cp -R public/* ${INIT_CWD}/public",
  "twill-watch-blocks": "watch 'npm run twill-copy-blocks' resources/assets/js/blocks --wait=2 --interval=0.1"
}

You can now start a Webpack HMR server on Twill's admin console UI assets using:

npm run twill-dev

This will refresh your browser tab or hot reload code when possible (keeping state when possible too) on any changes to your compiled blocks or Twill's frontend sources.

A note about the frontend

On your frontend domain (domain.test), nothing changed, and that's ok! Twill does not make any assumptions regarding how you might want to build your own applications. It is up to you to setup Laravel routes that queries content created through Twill's admin console. You can decide to use server side rendering with Laravel's Blade templating and/or to define API endpoints to build your frontend application using any client side solution (eg. Vue, React, Angular, ...).

On a clean Laravel install, you should still see Laravel's welcome screen. If you installed Twill on an existing Laravel application, your setup should not be affected. Do not hesitate to reach out on Github if you have a specific use case or any trouble using Twill with your existing application.

Configuration

As mentioned above, Twill's default configuration allows you to get up and running quickly by providing environment variables.

Of couse, you can override any of Twill's provided configurations values from the empty config/twill.php file that was published in your app when you ran the twill:install command.

Global configuration

By default, Twill uses Laravel default application namespace App. You can provide your own using the namespace configuration in your config/twill.php file:

<?php

return [
    'namespace' => 'App',
];

You can also change the default variables that control where Twill's admin console is available:

<?php

return [
    'admin_app_url' => env('ADMIN_APP_URL', 'admin.' . env('APP_URL')),
    'admin_app_path' => env('ADMIN_APP_PATH', ''),
];

If you have specific middleware needs, you can specify a custom middleware group for Twill's admin console routes:

<?php

return [
    'admin_middleware_group' => 'web',
];

Twill registers its own exception handler in all controllers. If you need to customize it (to report errors on a 3rd party service like Sentry or Rollbar for example), you can opt-out from it in your config/twill.php file:

<?php

return [
    'bind_exception_handler' => false,
];

And then extend it from your own app/Exceptions/Handler.php class:

<?php

namespace App\Exceptions;

use A17\Twill\Exceptions\Handler as ExceptionHandler;
use Exception;
use Illuminate\Auth\AuthenticationException;

class Handler extends ExceptionHandler
...

Twill's users and their password resets are stored in twill_users and twill_password_resets tables respectively. If you started your application on Twill 1.1 or simply would like to provide custom tables names, use the following configuration options:

<?php

return [
    'users_table' => 'twill_users',
    'password_resets_table' => 'twill_password_resets',
];

Enabled features

You can opt-in or opt-out from certain Twill features using the enabled array in your config/twill.php file. Values presented in the following code snippet are Twill's defaults:

<?php

return [
    'enabled' => [
        'users-management' => true,
        'media-library' => true,
        'file-library' => true,
        'dashboard' => true,
        'search' => true,
        'block-editor' => true,
        'buckets' => true,
        'users-image' => false,
        'users-description' => false,
        'site-link' => false,
        'settings' => false,
        'activitylog' => true,
    ],
];

You do not need to override entire arrays of configuration options. For example, if you only want to disable Twill's dashboard, you do not need to include to entire enabled array to your own config/twill.php configuration file:

<?php

return [
    'enabled' => [
        'dashboard' => false,
    ],
];

This is true to all following configuration arrays.

Media library

The media_library configuration array in config/twill.php allows you to provide Twill with your configuration for the media library disk, endpoint type and others options depending on your endpoint type. Most options can be controlled through environment variables, as you can see in the default configuration provided:

<?php

return [
    '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'],
        'init_alt_text_from_filename' => true,
        'translated_form_fields' => false,
    ],
];

Twill's media library supports the following endpoint types: s3 and local.

S3 endpoint

By default, Twill uses the s3 endpoint type to store your uploads on an AWS S3 bucket. To authorize uploads to S3, provide your application with the following environment variables:

S3_KEY=S3_KEY
S3_SECRET=S3_SECRET
S3_BUCKET=bucket-name

Optionally, you can use the S3_REGION variable to specify a region other than S3's default region (us-east-1).

When uploading images to S3, Twill sets the acl parameter to private. This is because images in your bucket should not be publicly accessible when using a service like Imgix on top of it. Only Imgix should have read-only access to your bucket, while your application obviously needs to have write access. If you intend to access images uploaded to S3 directly, set the MEDIA_LIBRARY_ACL variable or acl configuration option to public-read.

Local endpoint

If you want your uploads to be stored on the server where your Laravel application is running, use the local endpoint type. Define the MEDIA_LIBRARY_LOCAL_PATH environment variable or the media_library.local_path configuration option to provide Twill with your prefered upload path. Always include a trailing slash like in the following example:

MEDIA_LIBRARY_ENDPOINT_TYPE=local
MEDIA_LIBRARY_LOCAL_PATH=uploads/

To avoid running into too large errors when uploading to your server, you can choose to limit uploads through Twill using the MEDIA_LIBRARY_FILESIZE_LIMIT environment variable or filesize_limit configuration option. It is set to 50mb by default. Make sure to setup your PHP and webserver (apache, nginx, ....) to allow for the upload size specified here. When using the s3 endpoint type, uploads are not limited in size.

Cascading uploads deletions

By default, Twill will not delete images when deleting from Twill's media library UI, wether it is on S3 or locally.

You can decide to physically delete uploaded images using the cascade_delete option, which is also controlled through the MEDIA_LIBRARY_CASCADE_DELETE boolean environment variable:

MEDIA_LIBRARY_CASCADE_DELETE=false

Allowed extensions

The allowed_extensions configuration option is an array of file extensions that Twill's media library's uploader will accept. By default, svg, jpg, gif, png and jpeg extensions are allowed.

Images url rendering

To render uploaded image urls, Twill's prefered service is Imgix. You can change it using the MEDIA_LIBRARY_IMAGE_SERVICE environment variable or the media_library.image_service configuration option.

A simple local service is available (A17\Twill\Services\MediaLibrary\Local) but it will not make use of any cropping or resizing parameters when rendering urls. As noted in the media library's documentation, you can implement other third party services (eg. Cloudinary) or open source libraries (eg. Croppa) for that purpose if you do not want or cannot use Imgix for your project.

Imgix

As noted above, by default, Twill uses and recommends using Imgix to transform, optimize, and intelligently cache your uploaded images.

Specify your Imgix source url using the IMGIX_SOURCE_HOST environment variable or source_host configuration option.

IMGIX_SOURCE_HOST=source.imgix.net

By default, Twill will render Imgix urls with the https scheme. We do not see any reason why you would do so nowadays, but you can decide to opt-out using the IMGIX_USE_HTTPS environment variable or use_https configuration option.

Imgix offers the ability to use signed urls to prevent users from accessing images without parameters or different parameters than the ones you choose to use in your own application. You can enable that feature in Twill using the IMGIX_USE_SIGNED_URLS environment variable or use_signed_urls configuration option. If you enable signed urls, Imgix provides you with a signature key. Provide it to Twill using the IMGIX_SIGN_KEY environment variable.

IMGIX_USE_SIGNED_URLS=true
IMGIX_SIGN_KEY=xxxxxxxxxxxxxxxx

You should never store any sort of credentials in source control (eg. Git).

That's exactly why in the case of the Imgix signature key, we do not say that you could provide it to Twill using the sign_key configuration option of the imgix configuration array.

Always use environment variables for credentials.

Finally, Twill's default Imgix configuration includes 4 different image transformation parameter sets that are used by helpers you will find in the media library's documentation:

  • default_params: used by all image url functions in A17\Twill\Services\MediaLibrary\Imgix but overrided by the following parameter sets
  • lqip_default_params: used by the Low Quality Image Placeholder url function
  • social_default_params: used by the social image url function (for Facebook, Twitter, ... shares)
  • cms_default_params: used by the CMS image url function. This only affects rendering of images in Twill's admin console (eg. in the media library and image fields).

See Imgix's API reference for more information about those parameters.

<?php

return [
    'imgix' => [
        '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,
        ],
        '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'),
    ],
];

File library

The file_library configuration array in config/twill.php allows you to provide Twill with your configuration for the file library disk, endpoint type and other options depending on your endpoint type. Most options can be controlled through environment variables, as you can see in the default configuration provided:

<?php

return [
    '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's file library supports the following endpoint types: s3 and local.

S3 endpoint

By default, Twill uses the s3 endpoint type to store your uploads on an AWS S3 bucket. To authorize uploads to S3, provide your application with the following environment variables:

S3_KEY=S3_KEY
S3_SECRET=S3_SECRET
S3_BUCKET=bucket-name

Optionally, you can use the S3_REGION variable to specify a region other than S3's default region (us-east-1).

When uploading files to S3, Twill sets the acl parameter to public-read. This is because Twill's default file service produces direct S3 urls. If you do not intend to access files uploaded to S3 directly, set the FILE_LIBRARY_ACL variable or acl configuration option to public-read.

Local endpoint

If you want your uploads to be stored on the server where your Laravel application is running, use the local endpoint type. Define the FILE_LIBRARY_LOCAL_PATH environment variable or the file_library.local_path configuration option to provide Twill with your prefered upload path. Always include a trailing slash like in the following example:

FILE_LIBRARY_ENDPOINT_TYPE=local
FILE_LIBRARY_LOCAL_PATH=uploads/

To avoid running into too large errors when uploading to your server, you can choose to limit uploads through Twill using the FILE_LIBRARY_FILESIZE_LIMIT environment variable or filesize_limit configuration option. It is set to 50mb by default. Make sure to setup your PHP and webserver (apache, nginx, ....) to allow for the upload size specified here. When using the s3 endpoint type, uploads are not limited in size.

Cascading uploads deletions

By default, Twill will not delete files when deleting from Twill's file library's UI, wether it is on S3 or locally.

You can decide to physically delete uploaded files using the cascade_delete option, which is also controlled through the FILE_LIBRARY_CASCADE_DELETE boolean environment variable:

FILE_LIBRARY_CASCADE_DELETE=false

Files url service

Twill's provided service for files creates direct urls to the disk they were uploaded to (ie. S3 urls or urls on your domain depending on your endpoint type). You can change the default service using the FILE_LIBRARY_IMAGE_SERVICE environment variable or the file_library.image_service configuration option.

See the file library's documentation for more information.

Allowed extensions

The allowed_extensions configuration option is an array of file extensions that Twill's file library uploader will accept. By default, it is empty, all extensions are allowed.

Debug

The Laravel Debug Bar and Inspector packages are installed and registered by Twill, except on production environments.

On development, local and staging environment, Debug Bar is enabled by default. You can use Inspector instead by using the DEBUG_USE_INSPECTOR environment variable.

If you do not want to see the Debug Bar on the frontend of your Laravel application but want to keep it in Twill's admin console while developing or on staging servers, use the DEBUG_BAR_IN_FE environment variable:

DEBUG_BAR_IN_FE=false

And add the noDebugBar to your frontend route group middlewares. Example in a default Laravel 5.7 application's RouteServiceProvider:

Route::middleware('web', 'noDebugBar')
    ->namespace($this->namespace)
    ->group(base_path('routes/web.php'));

The config/twill-navigation.php file manages the navigation of your custom admin console. Using Twill's UI, the package provides 3 levels of navigation: global, primary and secondary. 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 contain more entries. A primary entry can define a secondary_navigation array that will contain even 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 in Twill's UI, you should structure your routes in the same way like the example here:

<?php

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

CRUD modules

CLI Generator

You can generate all the files needed in your application to create a new CRUD module using Twill's Artisan 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).

This 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 using the documentation below.

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

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

Depending on the depth of your module in your navigation, you'll need to wrap your route declaration in one or multiple nested route groups.

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

Setup your index options and columns in your controller if needed.

Enjoy.

Migrations

Generated 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. This is 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 available 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).

When using Twill's HasTranslation trait on a model, you are actually using the popular dimsav/translatable package. A default configuration will be automatically published to your config directory when you run the twill:install command.

To setup your list of available languages for translated fields, modify the locales array in config/translatable.php, using ISO 639-1 two-letter languages codes as in the following example:

<?php

return [
    'locales' => [
        'en',
        'fr',
    ],
    ...
];
  • 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

Repositories

Depending on the model feature, include one or multiple of these 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, $fields, 'relationName', 'ModelName', 'repeaterItemName');

    // 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', 'ModelName', 'repeaterItemName');

    // 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);
}

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
            // Take a look at the example in the next section fot the implementation of the sort
            '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.

Example Sort by Relationship Field.

Let's say we have a controller with certain fields displayed: File: app/Http/Controllers/Admin/PlayController.php

    protected $indexColumns = [
        'image' => [
            'thumb' => true, // image column
            'variant' => [
                'role' => 'featured',
                'crop' => 'default',
            ],
        ],
        'title' => [ // field column
            'title' => 'Title',
            'field' => 'title',
        ],
        'festivals' => [ // relation column
            'title' => 'Festival',
            'sort' => true,
            'relationship' => 'festivals',
            'field' => 'title'
        ],
    ];

For creating the Sorting mechanism for the relationship we need to overwrite the order method on the proper repository. In there we verify for the parameter sent which per convention should be relationship + field in this case festivalsTitle. Once applied we remove that parameter to avoid the application crash due to not being able to find the field on the table.

File: app/Repositories/PlayRepository.php

  ...
  public function order($query, array $orders = []) {

      if (array_key_exists('festivalsTitle', $orders)){
          $sort_method = $orders['festivalsTitle'];
          //Remove the UNEXISTING column from the orders array
          unset($orders['festivalsTitle']);
          $query = $query->orderByFestival($sort_method);
      }
      // don't forget to call the parent order function
      return parent::order($query, $orders);
  }
  ...

Then add the custom sort scope into your Model, it should be something like this: File: app/Models/Play.php

    public function scopeOrderByFestival($query, $sort_method = 'ASC') {
        return $query
            ->leftJoin('festivals', 'plays.section_id', '=', 'festivals.id')
            ->select('plays.*', 'festivals.id', 'festivals.title')
            ->orderBy('festivals.title', $sort_method);
    }

Form Requests

Classic Laravel 5 form request validation.

Once you generated the module using Twill's CLI module generator, it will also prepare the App/Http/Requests/Admin/ModuleNameRequest.php for you to use. 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
]);

Once you defined the rules in this file, the UI will show the corresponding validation error state or message next to the corresponding form field.

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)

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',
])

WYSIWYG field is based on Quill Rich Text Editor.

You can add all toolbar options from Quill with the toolbarOptions key.

For example, this configuration will render a wysiwyg field with almost all features from Quill and Snow theme.

 @formField('wysiwyg', [
    'name' => 'case_study',
    'label' => 'Case study text',
    'toolbarOptions' => [ 
      ["font" => ["serif", "sans-serif", "monospace"]],
      ['header' => [2, 3, 4, 5, 6, false]],
      'bold',
      'italic',
      'underline',
      'strike',
      ["color" => []],
      ["background" => []],
      ["script" => "super"],
      ["script" => "sub"],
      "blockquote",
      "code-block",
      ['list' => 'ordered'],
      ['list' => 'bullet'],
      ['indent' => '-1'],
      ['indent' => '+1'],
      ["align" => []],
      ["direction" => "rtl"],
      'link',
      'image',
      'video',
      "clean",
    ],
    'placeholder' => 'Case study text',
    'maxlength' => 200,
    'editSource' => true,
    'note' => 'Hint message`',
 ])

Note that Quill outputs CSS classes in the HTML for certain toolbar modules (indent, font, align, etc.), and that the image module is not integrated with Twill's media library. It outputs the base64 representation of the uploaded image. It is not a recommended way of using and storing images, prefer using one or multiple medias form fields or blocks fields for flexible content. This will give you greater control over your frontend output.

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'
])

Right after declaring the media formField in the blade template file, you still need to do a few things to make it works properly.

If the formField is in a static content form, you have to include the HasMedias Trait in your module's Model and inlcude HandleMedias in your module's Repository, in addition, you have to uncomment the $mediasParams section in your Model file to let the model know about fields you'd like to save from the form.

Learn more about how Twill's media configurations work at Model, Repository, Media Library Role & Crop Params

If the formField is used inside a block, you need to define the mediasParams at config/twill.php under crops key, and you are good to go. You could checkout Twill Default Configuration and Rendering Blocks for references.

If you need medias fields to be translatable (ie. publishers can select different images for each locale), set the twill.media_library.translated_form_fields configuration value to true.

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',
        'moduleName' => 'publications'
    ])
</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,
])

Similar to the media formField, to make the file field works, you have to include the HasFiles trait in your module's Model, and include HandleFiles trait in your module's Repository. At last, add the filesParams configuration array in your model.

public $filesParams = ['file_role', ...]; // a list of file roles

Learn more at Model, Repository.

If you are using the file formField in a block, you have to define the files key in config/twill.php and you are all set, put it under block_editor key and at the same level as crops key:

return [
    'block_editor' => [
        'crops' => [
            ...
        ],
        'files' => ['file_role1', 'file_role2', ...]
    ]

Map

screenshot

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

This field requires that you provide a GOOGLE_MAPS_API_KEY variable in your .env file.

Color

@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,
    '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'
        ],
    ]
])

Revisions and previewing

When using the HasRevisions trait, Twill's UI gives publishers the ability to preview their changes without saving, as well as to preview and compare old revisions.

If you are implementing your site using Laravel routing and Blade templating (ie. traditional server side rendering), you can follow Twill's convention of creating frontend views at resources/views/site and naming them according to their corresponding CRUD module name. When publishers try to preview their changes, Twill will render your frontend view within an iframe, passing the previewed record with it's unsaved changes to your view in the $item variable.

If you want to provide Twill with a custom frontend views path, use the frontend configuration array of your config/twill.php file:

return [
    'frontend' => [
        'views_path' => 'site',
    ],
    ...
];

If you named your frontend view differently than the name of its corresponding module, you can use the $previewView class property of your module's controller:

<?php
...

class ProjectController extends ModuleController
{
    protected $moduleName = 'projects';

    protected $previewView = 'custom-view-name';
    ...
}

If you want to provide the previewed view with extra variables or simply to rename the $item variable, you can implement the previewData function in your module's admin controller:

<?php
...
protected function previewData($item)
{
    return [
        'project' => $item,
        'setting_name' => $settingRepository->byKey('setting_name')
    ];
}

Nested Module

To create a nested module with parent/child relationships, you should include the laravel-nestedset package to your application.

To install the package: composer require kalnoy/nestedset

Then add nested set columns to your database table:

For Laravel 5.5 and above users:

Schema::create('pages', function (Blueprint $table) {
    ...
    $table->nestedSet();
});

// To drop columns
Schema::table('pages', function (Blueprint $table) {
    $table->dropNestedSet();
});

For prior Laravel Versions:

...
use Kalnoy\Nestedset\NestedSet;

Schema::create('pages', function (Blueprint $table) {
    ...
    NestedSet::columns($table);
});

// To drop columns
Schema::table('pages', function (Blueprint $table) {
    NestedSet::dropColumns($table);
});

Your model should use the Kalnoy\Nestedset\NodeTrait trait to enable nested sets, as well as the HasPosition trait and some helper functions to save a new tree organisation from Twill's drag and drop UI:

use A17\Twill\Models\Behaviors\HasPosition;
use Kalnoy\Nestedset\NodeTrait;
...

class Page extends Model {
    use HasPostion, NodeTrait;
    ...
    public static function saveTreeFromIds($nodesArray)
    {
        $parentNodes = self::find(array_pluck($nodesArray, 'id'));

        if (is_array($nodesArray)) {
            $position = 1;
            foreach ($nodesArray as $nodeArray) {
                $node = $parentNodes->where('id', $nodeArray['id'])->first();
                $node->position = $position++;
                $node->saveAsRoot();
            }
        }

        $parentNodes = self::find(array_pluck($nodesArray, 'id'));

        self::rebuildTree($nodesArray, $parentNodes);
    }

    public static function rebuildTree($nodesArray, $parentNodes)
    {
        if (is_array($nodesArray)) {
            foreach ($nodesArray as $nodeArray) {
                $parent = $parentNodes->where('id', $nodeArray['id'])->first();
                if (isset($nodeArray['children']) && is_array($nodeArray['children'])) {
                    $position = 1;
                    $nodes = self::find(array_pluck($nodeArray['children'], 'id'));
                    foreach ($nodeArray['children'] as $child) {
                        //append the children to their (old/new)parents
                        $descendant = $nodes->where('id', $child['id'])->first();
                        $descendant->position = $position++;
                        $descendant->parent_id = $parent->id;
                        $descendant->save();
                        self::rebuildTree($nodeArray['children'], $nodes);
                    }
                }
            }
        }
    }
}

From your module's repository, you'll need to override the setNewOrder function:

public function setNewOrder($ids)
{
    DB::transaction(function () use ($ids) {
        Page::saveTreeFromIds($ids);
    }, 3);
}

If you expect your users to create a lot of records, you'll want to move this operation into a queued job.

Finally, to enable Twill's nested listing UI, you'll need to do the following in your module's controller:

protected $indexOptions = [
    'reorder' => true,
];

protected function indexData($request)
{
    return [
        'nested' => true,
        'nestedDepth' => 2, // this controls the allowed depth in UI
    ];
}

protected function transformIndexItems($items)
{
    return $items->toTree();
}

protected function indexItemData($item)
{
    return ($item->children ? [
        'children' => $this->getIndexTableData($item->children),
    ] : []);
}

When using a browser to browse a nested module, if you expect to select children as well as parents, you will need to add the following function to your module's controller:

protected function getBrowserItems($scopes = [])
{
    return $this->repository->get(
        $this->indexWith,
        $scopes,
        $this->orderScope(),
        request('offset') ?? $this->perPage ?? 50,
        true
    );
}

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 ships 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 example, roles for a People model could be profile and cover. This would 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 screens, but could use a 16/9 crop on larger screens. Those values are editable at your convenience for each model, even if there are already some crops 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 $mediasParams 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 works 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)

Imgix and S3 direct uploads

On AWS, create a IAM user for full access to your S3 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 another IAM user for Imgix with read-only access to your bucket and use its credentials to create an S3 source on Imgix. 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 S3 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>https://YOUR_ADMIN_DOMAIN</AllowedOrigin>
        <AllowedOrigin>http://YOUR_ADMIN_DOMAIN</AllowedOrigin>
        <AllowedMethod>POST</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <AllowedMethod>DELETE</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <ExposeHeader>ETag</ExposeHeader>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration>

Imgix and local uploads

When setting up an Imgix source for local uploads, choose the Web Folder source type and specify your domain in the Base URL settings.

screenshot

Block editor

Adding blocks

The block editor form field lets you add content freely to your module. The blocks can be easily 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 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 will need to 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 has 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.

  • Not running npm run twill-build

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, it also won't work, 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',
        ],
    ]
  • To render a browser with items selected in the block, you can use the browserIds helper to retrieve the selected items' ids, and then you may use Eloquent method like find to get the actual records:
    $selected_items_ids = $block->browserIds('browserFieldName');
    $items = Item::find($selected_items_ids);

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 these Blade views, you will have access to a $blockvariable with a couple of helper functions 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 example:

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

Default configuration

return [
    'block_editor' => [
        'block_single_layout' => 'site.layouts.block',
        'block_views_path' => 'site.blocks',
        'block_views_mappings' => [],
        'block_preview_render_childs' => true,
        'blocks' => [
            'text' => [
                'title' => 'Body text',
                'icon' => 'text',
                'component' => 'a17-block-wysiwyg',
            ],
            'image' => [
                'title' => 'Image',
                'icon' => 'image',
                'component' => 'a17-block-image',
            ],
        ],
        'crops' => [
            'image' => [
                'desktop' => [
                    [
                        'name' => 'desktop',
                        'ratio' => 16 / 9,
                        'minValues' => [
                            'width' => 100,
                            'height' => 100,
                        ],
                    ],
                ],
                'tablet' => [
                    [
                        'name' => 'tablet',
                        'ratio' => 4 / 3,
                        'minValues' => [
                            'width' => 100,
                            'height' => 100,
                        ],
                    ],
                ],
                'mobile' => [
                    [
                        'name' => 'mobile',
                        'ratio' => 1,
                        'minValues' => [
                            'width' => 100,
                            'height' => 100,
                        ],
                    ],
                ],
            ],
        ],
    ],
    ...
];

Content Editor

You can enable the content editor individual block previews by providing a resources/views/site/layouts/block.blade.php blade layout file. The layout should be yielding a content section: @yield('content') with any frontend CSS/JS included exactly like in your main frontend layout. A simple example could be:

<!doctype html>
<html>
    <head>
        <title>#madewithtwill website</title>
        <link rel="stylesheet" href="/css/app.css">
    </head>
    <body>
        <div>
            @yield('content')
        </div>
        <script src="/js/app.js"></script>
    </body>
</html>

If you would like to specify a custom layout view path, you can do so in config/twill.php at twill.block_editor.block_single_layout. In order to share the most of the layout between your frontend and individual blocks (essentially its assets), you can also create a parent layout and extend it from both.

Dashboard

Once you have created and configured multiple CRUD modules in your Twill's admin console, you can configure Twill's dashboard in config/twill.php.

For each module that you want to enable in a part or all parts of the dashboad, add an entry to the dashboard.modules array, like in the following example:

return [
    'dashboard' => [
        'modules' => [
            'projects' => [ // module name if you added a morph map entry for it, otherwise FQCN of the model (eg. App\Models\Project)
                'name' => 'projects', // module name
                'label' => 'projects', // optional, if the name of your module above does not work as a label
                'label_singular' => 'project', // optional, if the automated singular version of your name/label above does not work as a label
                'routePrefix' => 'work', // optional, if the module is living under a specific routes group
                'count' => true, // show total count with link to index of this module
                'create' => true, // show link in create new dropdown
                'activity' => true, // show activities on this module in actities list
                'draft' => true, // show drafts of this module for current user 
                'search' => true, // show results for this module in global search
            ],
            ...
        ],
        ...
    ],
    ...
];

You can also enable a Google Analytics module:

return [
    'dashboard' => [
        ...,
        'analytics' => [
            'enabled' => true,
            'service_account_credentials_json' => storage_path('app/analytics/service-account-credentials.json'),
        ],
    ],
    ...
];

It is using Spatie's Laravel Analytics package.

Follow Spatie's documentation to setup a Google service account and download a json file containing your credentials, and provide your Analytics view ID using the ANALYTICS_VIEW_ID environment variable.

By default, Twill's global search input is always available in the dashboard and behind the top-right search icon on other Twill's screens. By default, the search input performs a LIKE query on the title attribute only. If you like, you can specify a custom list of attributes to search for in each dashboard enabled module:

return [
    'dashboard' => [
        'modules' => [
            'projects' => [
                'name' => 'projects',
                'routePrefix' => 'work',
                'count' => true,
                'create' => true,
                'activity' => true,
                'draft' => true,
                'search' => true,
                'search_fields' => ['name', 'description']
            ],
            ...
        ],
        ...
    ],
    ...
];

You can also customize the endpoint to handle search queries yourself:

return [
    'dashboard' => [
        ...,
        'search_endpoint' => 'your.custom.search.endpoint.route.name',
    ],
    ...
];

You will need to return a collection of values, like in the following example:

return $searchResults->map(function ($item) use ($module) {
    try {
        $author = $item->revisions()->latest()->first()->user->name ?? 'Admin';
    } catch (\Exception $e) {
        $author = 'Admin';
    }

    return [
        'id' => $item->id,
        'href' => moduleRoute($moduleName['name'], $moduleName['routePrefix'], 'edit', $item->id),
        'thumbnail' => $item->defaultCmsImage(['w' => 100, 'h' => 100]),
        'published' => $item->published,
        'activity' => 'Last edited',
        'date' => $item->updated_at->toIso8601String(),
        'title' => $item->title,
        'author' => $author,
        'type' => str_singular($module['name']),
    ];
})->values();

Featuring content

Twill's buckets allow you to provide publishers 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 different 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 bucketable should 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 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'
]

Settings sections

Settings sections are standalone forms that you can add to your Twill's navigation to give publishers the ability to manage simple key/value records for you to then use anywhere in your application codebase.

Start by enabling the settings feature in your config/twill.php configuration file enabled array. See Twill's configuration documentation for more information.

If you did not enable this feature before running the twill:install command, you need to copy the migration in vendor/area17/twill/migrations/create_settings_table.php to your own database/migrations directory and migrate your database before continuing.

To create a new settings section, add a blade file to your resources/views/admin/settings folder. The name of this file is the name of your new settings section.

In this file, you can use @formField('input') Blade directives to add new settings. The name attribute of each form field is the name of a setting. Wrap them like in the following example:

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

@section('contentFields')
    @formField('input', [
        'label' => 'Site title',
        'name' => 'site_title',
        'textLimit' => '80'
    ])
@stop

If your translatable.locales configuration array contains multiple language codes, you can enable the translated option on your settings input form fields to make them translatable.

At this point, you want to add an entry in your config/twill-navigation.php configuration file to show the settings section link:

return [
    ...
    'settings' => [
        'title' => 'Settings',
        'route' => 'admin.settings',
        'params' => ['section' => 'section_name'],
        'primary_navigation' => [
            'section_name' => [
                'title' => 'Section name',
                'route' => 'admin.settings',
                'params' => ['section' => 'section_name']
            ],
            ...
        ]
    ],
];

Each Blade file you create in resources/views/admin/settings creates a new section available for you to add in the primary_navigation array of your config/twill-navigation.php file.

You can then retrieve the value of a specific setting by its key, which is the name of the form field you defined in your settings form, either by directly using the A17\Twill\Models\Setting Eloquent model or by using the provided byKey helper in A17\Twill\Repositories\SettingRepository:

<?php

use A17\Twill\Repositories\SettingRepository;
...

app(SettingRepository::class)->byKey('site_title');
app(SettingRepository::class)->byKey('site_title', 'section_name');

User management

Authentication and authorization are provided by default in Laravel. This package simply leverages what Laravel provides and configures 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 users 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.

To create new user roles, you could extend the default enum UserRole by overriding it using Composer autoloading. In composer.json:

    "autoload": {
        "classmap": [
            "database/seeds",
            "database/factories"
        ],
        "psr-4": {
            "App\\": "app/"
        },
        "files": ["app/Models/Enums/UserRole.php"],
        "exclude-from-classmap": ["vendor/area17/twill/src/Models/Enums/UserRole.php"]
    }

In app/Models/Enums/UserRole.php (or anywhere else you'd like actually, only the namespace needs to be the same):

    <?php

    namespace A17\Twill\Models\Enums;

    use MyCLabs\Enum\Enum;

    class UserRole extends Enum
    {
        const CUSTOM1 = 'Custom role 1';
        const CUSTOM2 = 'Custom role 2';
        const CUSTOM3 = 'Custom role 3';
        const ADMIN = 'Admin';
    }

Finally, in your AuthServiceProvider class, redefine Twill's default permissions if you need to, or add your own, for example:

    <?php

    namespace App\Providers;

    use A17\Twill\Models\Enums\UserRole;
    use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
    use Illuminate\Support\Facades\Gate;

    class AuthServiceProvider extends ServiceProvider
    {
        public function boot()
        {
            Gate::define('list', function ($user) {
                return in_array($user->role_value, [
                    UserRole::CUSTOM1,
                    UserRole::CUSTOM2,
                    UserRole::ADMIN,
                ]);
            });

        Gate::define('edit', function ($user) {
                return in_array($user->role_value, [
                    UserRole::CUSTOM3,
                    UserRole::ADMIN,
                ]);
            });

            Gate::define('custom-permission', function ($user) {
                return in_array($user->role_value, [
                    UserRole::CUSTOM2,
                    UserRole::ADMIN,
                ]);
            });
        }
    }

You can use your new permission and existing ones in many places like the twill-navigation configuration using can:

    'projects' => [
        'can' => 'custom-permission',
        'title' => 'Projects',
        'module' => true,
    ],

Also in forms blade files using @can, as well as in middleware definitions in routes or controllers, see Laravel's documentation for more info.

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.

Resources

Other useful packages