An introduction to Sharp for Laravel, an open source content management framework
Published on by Philippe Lonchampt
Let's start with two facts that every web developer knows:
(1) content management is hard, on both sides: it's difficult to build (and to maintain!) an adapted tool as a developer, and it is often a pain to use it as a content manager.
(2) this topic has been adressed many times, as the rule states: "everytime a developer faces a particular content management case, he builds a custom CMS". Fortunately there are good commercial projects (Laravel Nova, Statamic to name a few), very good open source packages in the Laravel world (Filament for instance), and a galaxy of other options (headless CMS is one of them).
I wrote the first version of Sharp for Laravel before most of these tools, in the early days of Laravel, mainly because I was very frustrated by Wordpress — for many reasons. Sharp was messy back then! But after a few years and lot of work, and with the help on the front side of Antoine Guingand who joined Code 16, we finally managed to launch Sharp 4 in 2017, which was a decent product I think. We built a lot of features and did a lot of refactoring since then, leading us to the recent release of version 8.
This article should be read as an introduction, and is quite specific: my goal is to show through an example how to leverage Sharp in some existing Laravel project to build admin tools quickly and efficiently, rather than focusing on actual content management — which should the the core of a tool like Sharp, right? Well, yes of course, but being able to bring useful business oriented tools and information to an administrator is really important too (that said, Sharp is very good at content management).
Diving in! first step: build a List
When you install code16/sharp
on a Laravel Project, as a composer dependency, the only thing you’ll get is a new /sharp
route, with a login form. Everything after this login must be configured and developed, leveraging Sharp’s API.
Here's our scenario: you built an e-commerce website for a local shop, linked to some software they have for product and stock management. At first there was no need for any admin section, since the product listing comes from an external API provided by this software, but the client now wants to be able to list online products; so we decided to add Sharp to the project, to display the detailed product list to the admin (ref, price, whatever).
For this, we first declare a ProductEntity
(in Sharp, an Entity
is a manageable thing; it’s typically a Model, but it can be anything), and develop a ProductList
.
class ProductEntity extends \Code16\Sharp\Utils\Entities\SharpEntity{ protected ?string $list = ProductList::class;}
The ProductList
code could be written like this:
// use statements striped for readability class ProductList extends SharpEntityList{ public function __construct(protected ProductApiClient $productApiClient) { } protected function buildListFields(EntityListFieldsContainer $fieldsContainer): void { $fieldsContainer ->addField( EntityListField::make('reference') ->setLabel('Ref.') ->setSortable() ->setWidth(3) ->setWidthOnSmallScreensFill() ) ->addField( EntityListField::make('name') ->setLabel('Name') ->setWidth(6) ->hideOnSmallScreens() ) ->addField( EntityListField::make('price') ->setLabel('Price') ->setSortable() ->setWidth(3) ->setWidthOnSmallScreensFill() ); } public function buildListConfig(): void { $this->configureDefaultSort('reference') ->configureSearchable(); } public function getFilters(): array { return [ ProductPriceFilter::class ]; } public function getListData(): array|Arrayable { return $this ->setCustomTransformer('price', fn ($value) => number_format($value, 2) . ' €'); ->transform( $this->productApiClient ->fetchProducts([ 'sort' => [ 'column' => $this->queryParams->sortedBy(), 'dir' => $this->queryParams->sortedDir(), ], 'price_filter' => match(this->queryParams->filterFor(ProductPriceFilter::class)) { 'sale' => 'sale_only', default => '', }, 'search_query' => $this->queryParams->searchWords(), ]) ); } }
Let's review this code with a quick breakdown:
-
buildListField()
contains the structure; in this case, this is a list, therefore fields are columns: we declare three of them, two of which aresortable
, and use some self-explaining code to define a layout. -
getListData()
does the hard work: this method must return an array version of each product, in a global array (or aPaginator
, which obviously would be pertinent here, but let's keep it simple). Here we use someProductApiClient
injected in the constructor (this class is supposed to call the external inventory management software), passing parameters given what's in$this->queryParams
, which is an object that Sharp keep in sync with the user request. Also note that we make use of a transformation API built in Sharp which simplifies the work in many ways. - in
buildListConfig()
we can configure the list in many ways, calling API methods which start withconfigure...
. - Filters declared in
getFilters()
are quite easy to build (let's skip the implementation to keep it simple), and allows to display... well, filters, in the list.
With this, our list is working:
Leverage Eloquent and add functional commands
The project evolves, and now we must provide a way to partially update products in the website (maybe to show a detailed description and a list of visuals). To this end, we made the decision to replace our simple ProductApiClient
with a more sophisticated scheduled job which would fill a local products
table in the project database.
This means we now have a Product
Eloquent Model, and so in our ProductList
, we should only change the getListData()
code:
class ProductList extends SharpEntityList{ // [...] public function getEntityCommands(): array { return [ SynchronizeProductsFromApi::class ]; } public function getListData(): array|Arrayable { return $this ->setCustomTransformer('price', fn ($value) => number_format($value, 2) . ' €') ->transform( Product::query() ->when($this->queryParams->filterFor(ProductPriceFilter::class), function ($query, $filterValue) { return $query->where('price_status', $filterValue); }) ->when($this->queryParams->hasSearch(), function ($query) { foreach ($this->queryParams->searchWords() as $word) { $query->where('name', 'like', $word); } return $query; }) ->orderBy($this->queryParams->sortedBy(), $this->queryParams->sortedDir()) ->get() ); }}
You may have spotted that we also added an EntityCommand
in the process, which should be used to call the products synchronization job. Here's how it can be implemented:
class SynchronizeProductsFromApi extends EntityCommand{ public function label(): ?string { return 'Synchronize products...'; } public function buildCommandConfig(): void { $this->configureConfirmationText('Launch a products synchronization, as a background task?'); } public function buildFormFields(FieldsContainer $formFields): void { $formFields ->addField( SharpFormCheckField::make('all', 'Sync all products') ) ->addField( SharpFormDateField::make('start') ->setLabel('Sync products updated after') ->addConditionalDisplay('!all') ); } public function execute(array $data = []): array { $this->validate($data, [ 'start' => [ 'required_if:all,false', 'after:now', ] ]); ProductSynchronizer::dispatch($data['all'] ? null : $data['start']); return $this->info('Synchronization queued. Should be finished in a few minutes.'); }}
This command is defined with an Entity scope, meaning it applies to the whole list and appears in an "Actions" dropdown above it; since it defines form fields in the buildFormFields()
method, it presents a form in a modal:
In a similar fashion we can create "Instance" scoped commands, attached to each row; to learn more about this, and about authorizations, wizards commands, bulk commands... refer to the dedicated documentation.
Embed a List in a Show Page
From there I could naturally talk about Forms, to demonstrate how to update complex objects with relations, uploads, rich texts with custom embeds, validation and more — but as I said in the introduction of this article I'll skip this part, and I leave you with the documentation to find out how to handle forms. Let's finally add another Entity for a customer Order
, but instead of showing the List code, this time we are going to focus on an optional intermediate page between a List and a Form: the Show Page.
class OrderShow extends SharpShow{ protected function buildShowFields(FieldsContainer $showFields): void { $showFields ->addField( SharpShowTextField::make('ref') ->setLabel('Reference') ) ->addField( SharpShowTextField::make('created_at') ->setLabel('Date') ) ->addField( SharpShowTextField::make('customer:name') ->setLabel('Name') ) ->addField( SharpShowTextField::make('customer:email') ->setLabel('Email') ) ->addField( // [...] more fields, cut for brievty ) ->addField( SharpShowEntityListField::make('rows', 'product') ->setLabel('Rows') ->hideFilterWithValue('price', 'all') ->showSearchField(false) ->hideEntityCommand([SynchronizeProducts::class]) ->hideFilterWithValue('order', fn ($instanceId) => $instanceId) ); } protected function buildShowLayout(ShowLayout $showLayout): void { $showLayout ->addSection('Order', function (ShowLayoutSection $section) { $section ->addColumn(6, function (ShowLayoutColumn $column) { $column ->withSingleField('ref') ->withSingleField('created_at'); }) ->addColumn(6, function (ShowLayoutColumn $column) { $column ->withFields('total|6', 'shipping_cost|6'); }); }) ->addSection('Customer', function (ShowLayoutSection $section) { // [...] cut for brievty }) ->addEntityListSection('rows'); } protected function find(mixed $id): array { return $this ->setCustomTransformer( 'total', fn ($value, Order $order) => sprintf('%s €', number_format($order->rows->sum('price'), 2)) ) ->setCustomTransformer( 'shipping_cost', fn ($value, Order $order) => sprintf('%s €', number_format(count($order->rows) * 1.8, 2)) ) ->setCustomTransformer( 'created_at', fn ($value, Order $order) => $order->created_at->isoFormat('LLLL') ) ->setCustomTransformer( 'address', fn ($value, Order $order) => $order->delivery_mode === 'ship' ? $value : null ) ->transform(Order::findOrFail($id)); }}
We defined simple fields in buildShowFields()
, and decided how they should appear in buildShowLayout()
; notice a big difference with the List: we have a find()
method, responsible of returning a single instance.
But really I want to point out one particular useful feature of the Show Page, which is that we can embed Lists in it. In the code above, we've declared a "rows" SharpShowEntityListField
which is leveraging our already developed ProductList
, calling ->hideFilterWithValue('order', fn ($instanceId) => $instanceId)
to filter only products attached to this particular order. This implies to handle this new filter in ProductList
:
class ProductList extends SharpEntityList{ // [...] public function getListData(): array|Arrayable { return $this // [...] ->transform( Product::query() ->when($this->queryParams->filterFor('order'), function ($query, $orderId) { return $query->whereHas('order', fn ($query) => $query->where('id', $orderId)); }) // [...] ->get() ); }}
Here's the result:
This embedded List has all the features of the regular one (filters, search, sort, reorder, commands...), and even better it is browsable: a click on one of the product leads to his Form or to another Show Page, with the opportunity to pile up more stuff. The breadcrumb keeps track of you path, allowing to show useful context to the admin, very clearly ("I'm editing the registrant John for the April session of the ‘How to use Tailwind’ course").
Yeah, but what about real world applications?
First, notice that I made the effort to use products and orders for this article, and not blog posts and authors 😅; I think this dummy example is sufficient to show how Sharp proceed to respect what should be, to me, the golden rules of a content management framework: no code adherence between website and CMS, clean terminology, no front-end development, and free choice of persistence layer.
To see a more advanced example (which actually leverages some of the new features of version 8, such as 2FA auth, global search, bulk commands...) you can test the online demo of Sharp, which is about posts, and check its code on Github.
I think the real big advantage of Sharp comes with a bit of experience, and that's productivity: at some point it is fast to build complex features on top of a simple CRUD, focusing mainly on the functionality itself, without losing control of the code or compromising the project architecture. And because it features a simple and consistent UI (yes, you can change that blue color and add your logo), administrators and content managers will hopefully appreciate Sharp too.
As for real world apps, in our web company we use it on almost all our projects: websites, mobile apps, SPA... Here's a few examples, illustrating the diversity of usages:
- in the e-commerce field, we developed in Sharp a full product and order management, allowing partial changes and state updates, preserving full history, syncing with external APIs for shipping, stocks and refunds, presenting sales and todo dashboards, managing the website content, and more.
- We developed websites really focused on the frontend, with various types of content (images, videos, embeds, custom embeds...): for this kind of projects, Sharp’s Editor field is a bless.
- For a mobile app with an API, Sharp is leveraged to handle the content, to keep track of connections, and to handle API keys.
- We also use Sharp as a side tool, to quickly add a way to handle users (and 2FA login), permissions or configuration on existing apps which already had an admin section less flexible.
That's it for this article. The easier way to reach me for any comment or question on this is (still) X / twitter, or via the dedicated Discord server.
Web developer at (and founder of) Code 16 • maintainer of Sharp for Laravel http://sharp.code16.fr