An introduction to Sharp for Laravel, an open source content management framework

Published on by

An introduction to Sharp for Laravel, an open source content management framework image

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 are sortable, 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 a Paginator, which obviously would be pertinent here, but let's keep it simple). Here we use some ProductApiClient 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 with configure....
  • 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.

Philippe Lonchampt photo

Web developer at (and founder of) Code 16 • maintainer of Sharp for Laravel http://sharp.code16.fr

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Forge
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
Larafast: Laravel SaaS Starter Kit logo

Larafast: Laravel SaaS Starter Kit

Larafast is a Laravel SaaS Starter Kit with ready-to-go features for Payments, Auth, Admin, Blog, SEO, and beautiful themes. Available with VILT and TALL stacks.

Larafast: Laravel SaaS Starter Kit
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
DirectoryTree Authorization is a Native Role and Permission Management Package for Laravel image

DirectoryTree Authorization is a Native Role and Permission Management Package for Laravel

Read article
Sort Elements with the Alpine.js Sort Plugin image

Sort Elements with the Alpine.js Sort Plugin

Read article
Anonymous Event Broadcasting in Laravel 11.5 image

Anonymous Event Broadcasting in Laravel 11.5

Read article
Microsoft Clarity Integration for Laravel image

Microsoft Clarity Integration for Laravel

Read article
Apply Dynamic Filters to Eloquent Models with the Filterable Package image

Apply Dynamic Filters to Eloquent Models with the Filterable Package

Read article
Property Hooks Get Closer to Becoming a Reality in PHP 8.4 image

Property Hooks Get Closer to Becoming a Reality in PHP 8.4

Read article