# Sharp - Laravel Content Management Framework ## Introduction Sharp is a content management framework built for Laravel as a package, providing a clean and intuitive UI for building CMS sections in projects. Developed and maintained by Code16, Sharp is driven by code rather than configuration, offering a documented PHP API that follows Laravel conventions and coding style. The framework is designed to be data-agnostic with no expectations from the persistence layer, avoiding code adherence so the main project has no knowledge of Sharp's existence. It's built with modern technologies including Inertia.js, Vue.js, Tailwind CSS, and requires Laravel 11+ with PHP 8.3+. Sharp provides comprehensive solutions for creating, updating, and deleting structured data with validation and error handling, displaying and filtering data through entity lists, executing custom commands on instances or selections, handling authorizations, and managing complex forms with rich content editors. The framework includes built-in support for multi-language content, file uploads with automatic image optimization, dashboards with various widget types, and a flexible authorization system. All functionality is accessible through a clean PHP API without writing frontend code, making it ideal for content-driven websites, e-commerce platforms, and API backends. ## Form Creation Building forms with SharpForm SharpForm is the base class for creating forms to edit or create entity instances. Forms define fields, layout, validation rules, and data transformation logic. ```php addField( SharpFormTextField::make('title') ->setLabel('Title') ->setLocalized() ->setMaxLength(150) ) ->addField( SharpFormEditorField::make('content') ->setLabel('Post content') ->setLocalized() ->setToolbar([ SharpFormEditorField::H2, SharpFormEditorField::B, SharpFormEditorField::UL, SharpFormEditorField::A, SharpFormEditorField::QUOTE, ]) ->setMaxLength(2000) ) ->addField( SharpFormUploadField::make('cover') ->setMaxFileSize(1) ->setImageOnly() ->setImageCropRatio('16:9') ->setStorageDisk('local') ->setStorageBasePath('data/posts/{id}') ) ->addField( SharpFormTagsField::make('categories', Category::pluck('name', 'id')->toArray()) ->setLabel('Categories') ->setCreatable() ->setCreateAttribute('name') ) ->addField( SharpFormDateField::make('published_at') ->setLabel('Publication date') ->setHasTime() ); } public function buildFormLayout(FormLayout $formLayout): void { $formLayout ->addTab('Content', function (FormLayoutTab $tab) { $tab->addColumn(6, function (FormLayoutColumn $column) { $column ->withField('title') ->withFields('published_at', 'categories'); }) ->addColumn(6, function (FormLayoutColumn $column) { $column ->withField('cover') ->withField('content'); }); }); } public function buildFormConfig(): void { $this->configureDisplayShowPageAfterCreation() ->configureEditTitle('Edit post') ->configureCreateTitle('New post'); } public function find($id): array { return $this ->setCustomTransformer('cover', new SharpUploadModelFormAttributeTransformer()) ->transform(Post::with('categories')->findOrFail($id)); } public function rules(): array { return [ 'title.en' => ['required', 'string', 'max:150'], 'title.fr' => ['required', 'string', 'max:150'], 'content.en' => ['nullable', 'string', 'max:2000'], 'published_at' => ['required', 'date'], ]; } public function update($id, array $data) { $post = $id ? Post::findOrFail($id) : new Post(['author_id' => auth()->id()]); $this->save($post, $data); if (sharp()->context()->isCreation()) { $this->notify('Your post was created successfully.'); } return $post->id; } public function getDataLocalizations(): array { return ['en', 'fr']; } } ``` ## Entity List Creation Building entity lists with SharpEntityList SharpEntityList provides the base class for creating searchable, filterable, sortable lists of entities with bulk actions and quick creation capabilities. ```php addField( EntityListField::make('name') ->setLabel('Name') ->setSortable() ) ->addField( EntityListField::make('posts_count') ->setLabel('# posts') ->setWidth(0.2) ); } public function buildListConfig(): void { $this ->configureSearchable() ->configureReorderable(new SimpleEloquentReorderHandler(Category::class)) ->configureDefaultSort('created_at', 'desc') ->configureQuickCreationForm(['name']); } protected function getEntityCommands(): ?array { return [ CleanUnusedCategoriesCommand::class, ]; } protected function getFilters(): ?array { return [ new class() extends CheckFilter { public function buildFilterConfig(): void { $this->configureKey('orphan') ->configureLabel('Orphan categories only'); } }, ]; } public function getListData(): array|Arrayable { $categories = Category::withCount('posts') ->orderBy('order') ->when( $this->queryParams->filterFor('orphan'), fn ($q) => $q->having('posts_count', 0) ) ->when( $this->queryParams->hasSearch(), fn ($q) => $q->where('name', 'like', '%' . $this->queryParams->searchWords()[0] . '%') ) ->when( $this->queryParams->sortedBy(), fn ($q) => $q->orderBy( $this->queryParams->sortedBy(), $this->queryParams->sortedDir() ) ); return $this->transform($categories->get()); } public function delete(mixed $id): void { Category::findOrFail($id)->delete(); } } ``` ## Show Page Creation Building show pages with SharpShow SharpShow creates detailed view pages for displaying entity instances with support for embedded lists, dashboards, and custom transformers. ```php addField( SharpShowTextField::make('content') ->setLocalized() ->collapseToWordCount(40) ) ->addField(SharpShowTextField::make('author')->setLabel('Author')) ->addField(SharpShowTextField::make('categories')->setLabel('Categories')) ->addField(SharpShowPictureField::make('cover')) ->addField( SharpShowListField::make('attachments') ->setLabel('Attachments') ->addItemField(SharpShowTextField::make('title')->setLabel('Title')) ->addItemField(SharpShowTextField::make('link_url')->setLabel('External link')) ->addItemField(SharpShowFileField::make('document')->setLabel('File')) ); } protected function buildShowLayout(ShowLayout $showLayout): void { $showLayout ->addSection('Details', function (ShowLayoutSection $section) { $section ->addColumn(7, function (ShowLayoutColumn $column) { $column ->withFields(categories: 5, author: 7) ->withListField('attachments', function (ShowLayoutColumn $item) { $item->withFields(title: 6, link_url: 6, document: 6); }); }) ->addColumn(5, function (ShowLayoutColumn $column) { $column->withField('cover'); }); }) ->addSection('Content', function (ShowLayoutSection $section) { $section ->setKey('content-section') ->addColumn(8, function (ShowLayoutColumn $column) { $column->withField('content'); }); }); } public function buildShowConfig(): void { $this ->configurePageTitleAttribute('title', localized: true) ->configureDeleteConfirmationText('Are you sure you want to delete this post?'); } protected function buildPageAlert(PageAlert $pageAlert): void { $pageAlert ->setLevelInfo() ->setMessage(fn (array $data) => $data['is_published'] ? 'This post is currently published' : 'This post is in draft mode' ); } public function getInstanceCommands(): ?array { return [ 'content-section' => [PreviewPostCommand::class], ]; } public function find(mixed $id): array { $post = Post::with('attachments', 'categories', 'author')->findOrFail($id); return $this ->setCustomTransformer('author', fn ($value, $instance) => $instance->author ? $instance->author->name : 'Unknown' ) ->setCustomTransformer('categories', fn ($value, $instance) => $instance->categories->pluck('name')->join(', ') ) ->setCustomTransformer('cover', new SharpUploadModelThumbnailUrlTransformer(500)) ->transform($post); } public function delete(mixed $id): void { Post::findOrFail($id)->delete(); } } ``` ## Dashboard Creation Building dashboards with SharpDashboard SharpDashboard provides widget-based dashboards with graphs, figures, lists, and custom panels for data visualization. ```php addWidget( SharpBarGraphWidget::make('posts_by_author') ->setTitle('Posts by author') ->setShowLegend(false) ->setHorizontal() ) ->addWidget( SharpPieGraphWidget::make('posts_by_category') ->setTitle('Posts by category') ) ->addWidget( SharpLineGraphWidget::make('visits') ->setTitle('Monthly visits') ->setHeight(200) ->setShowLegend() ->setCurvedLines() ) ->addWidget( SharpFigureWidget::make('draft_count') ->setTitle('Draft posts') ) ->addWidget( SharpFigureWidget::make('published_count') ->setTitle('Published posts') ) ->addWidget( SharpOrderedListWidget::make('top_categories') ->setTitle('Top 5 categories') ); } protected function buildDashboardLayout(DashboardLayout $dashboardLayout): void { $dashboardLayout ->addSection('Overview', function (DashboardLayoutSection $section) { $section->addRow(function (DashboardLayoutRow $row) { $row->addWidget(6, 'draft_count') ->addWidget(6, 'published_count'); }); }) ->addSection('Statistics', function (DashboardLayoutSection $section) { $section ->addRow(fn (DashboardLayoutRow $row) => $row ->addWidget(6, 'posts_by_author') ->addWidget(6, 'posts_by_category') ) ->addFullWidthWidget('visits') ->addRow(fn (DashboardLayoutRow $row) => $row ->addWidget(12, 'top_categories') ); }); } protected function buildWidgetsData(): void { // Figure widgets $this->setFigureData( 'draft_count', figure: Post::where('state', 'draft')->count(), evolution: '+15%' ); $this->setFigureData( 'published_count', figure: Post::where('state', 'published')->count(), unit: 'post(s)', evolution: '-10%' ); // Bar graph $authorData = User::withCount('posts') ->orderBy('posts_count', 'desc') ->limit(5) ->get() ->pluck('posts_count', 'name'); $this->addGraphDataSet( 'posts_by_author', SharpGraphWidgetDataSet::make($authorData) ->setColor('#2a9d90') ); // Pie graph Category::withCount('posts') ->orderBy('posts_count', 'desc') ->limit(5) ->get() ->each(function (Category $category) { $this->addGraphDataSet( 'posts_by_category', SharpGraphWidgetDataSet::make([$category->posts_count]) ->setLabel($category->name) ->setColor('#' . dechex(rand(0x000000, 0xFFFFFF))) ); }); // Line graph with multiple datasets $visits = ['Jan' => 1000, 'Feb' => 1200, 'Mar' => 1100, 'Apr' => 1400]; $this->addGraphDataSet( 'visits', SharpGraphWidgetDataSet::make($visits) ->setLabel('Total visits') ->setColor('#2a9d90') ); // Ordered list $this->setOrderedListData( 'top_categories', Category::withCount('posts') ->orderBy('posts_count', 'desc') ->limit(5) ->get() ->map(fn (Category $cat) => [ 'label' => $cat->name, 'count' => $cat->posts_count, ]) ->toArray() ); } } ``` ## Command Creation Building entity and instance commands Commands execute actions on entities or instances with optional forms and various return types for user feedback. ```php configureConfirmationText( 'Delete all categories without post attached?', title: 'Are you sure?' )->configureDescription('This action will remove all orphan categories'); } public function execute(array $data = []): array { $deletedCount = Category::whereDoesntHave('posts')->delete(); if ($deletedCount === 0) { throw new SharpApplicativeException('No unused category found!'); } $this->notify($deletedCount . ' categories were deleted!') ->setLevelInfo(); return $this->reload(); } } // Instance Command with form: acts on specific instance namespace App\Sharp\Commands; use App\Models\User; use Code16\Sharp\EntityList\Commands\EntityCommand; use Code16\Sharp\Form\Fields\SharpFormTextField; use Code16\Sharp\Utils\Fields\FieldsContainer; class InviteUserCommand extends EntityCommand { public function label(): ?string { return 'Invite new author...'; } public function buildCommandConfig(): void { $this->configureFormModalTitle('Invite a new user as author') ->configureFormModalDescription('Provide email and name for the new author.') ->configureFormModalButtonLabel('Send invitation') ->configureIcon('lucide-user-plus'); } public function buildFormFields(FieldsContainer $formFields): void { $formFields ->addField(SharpFormTextField::make('email')->setLabel('Email')) ->addField(SharpFormTextField::make('name')->setLabel('Name')); } public function execute(array $data = []): array { $this->validate($data, [ 'email' => ['required', 'email', 'max:100', 'unique:users,email'], 'name' => ['required', 'string', 'max:100'], ]); $user = User::create([ 'email' => $data['email'], 'name' => $data['name'], 'password' => bcrypt(Str::random()), ]); // Return types available: // return $this->reload(); // return $this->refresh([$user->id]); // return $this->info('Success message', reload: true); // return $this->link('/some-url', openInNewTab: true); // return $this->download($filePath, $fileName); // return $this->view('blade.view', ['data' => $value]); return $this->info('Invitation sent!', reload: true); } } ``` ## Configuration and Setup Sharp application configuration Configure Sharp's menu, authentication, uploads, theme, and entity registration through the service provider or config file. ```php setName('My CMS') ->setCustomUrlSegment('admin') // Entity registration ->declareEntity(PostEntity::class) ->declareEntity(CategoryEntity::class) // Or auto-discover: ->discoverEntities(['Sharp/Entities']) // Menu configuration ->setSharpMenu(MySharpMenu::class) // Search ->enableGlobalSearch(MySearchEngine::class, 'Search...') // Authentication ->setLoginAttributes('email', 'password') ->setUserDisplayAttribute('name') ->setUserAvatarAttribute('avatar_url') ->enableImpersonation() ->enableForgottenPassword() ->enable2faByNotification() // Uploads configuration ->configureUploads('local', 'tmp', 5) // disk, path, max size in MB ->configureUploadsThumbnailCreation('public', 'thumbnails') // Theme customization ->setThemeColor('#004c9b') ->setThemeLogo('/logo.png', '2rem', '/favicon.ico') // Middleware ->appendToMiddleware(MyMiddleware::class) ->get(); // Menu configuration class class MySharpMenu extends SharpMenu { public function build(): self { return $this ->setUserMenu(function (SharpMenuUserMenu $userMenu) { $userMenu ->addEntityLink(ProfileEntity::class, 'My Profile', 'lucide-user') ->addExternalLink('https://docs.example.com', 'Documentation'); }) ->addSection('Content', function (SharpMenuItemSection $section) { $section ->setCollapsible(false) ->addEntityLink(PostEntity::class, 'Posts', 'lucide-file-text') ->addEntityLink(CategoryEntity::class, 'Categories', 'lucide-tags'); }) ->addEntityLink(DashboardEntity::class, 'Dashboard', 'lucide-layout-dashboard'); } } // Entity configuration use Code16\Sharp\Utils\Entities\SharpEntity; class PostEntity extends SharpEntity { protected ?string $list = PostList::class; protected ?string $form = PostForm::class; protected ?string $show = PostShow::class; protected ?string $policy = PostPolicy::class; protected string $label = 'Posts'; } ``` ## Advanced Form Features Complex form fields and conditional display Sharp provides advanced form fields including repeatable lists, autocomplete, conditional display, and live refresh capabilities. ```php addField( SharpFormListField::make('attachments') ->setLabel('Attachments') ->setAddable()->setAddText('Add attachment') ->setRemovable() ->setMaxItemCount(5) ->setSortable()->setOrderAttribute('order') ->allowBulkUploadForField('document') ->addItemField( SharpFormTextField::make('title')->setLabel('Title') ) ->addItemField( SharpFormCheckField::make('is_link', 'It's a link') ) ->addItemField( SharpFormTextField::make('link_url') ->setPlaceholder('URL of the link') ->addConditionalDisplay('is_link') // Show only when is_link is true ) ->addItemField( SharpFormUploadField::make('document') ->setMaxFileSize(2) ->setAllowedExtensions(['pdf', 'zip', 'mp4']) ->setStorageDisk('local') ->setStorageBasePath('data/posts/{id}') ->addConditionalDisplay('!is_link') // Show only when is_link is false ) ); // Remote autocomplete with search callback $formFields->addField( SharpFormAutocompleteRemoteField::make('author_id') ->setLabel('Author') ->setReadOnly(!auth()->user()->isAdmin()) ->allowEmptySearch() ->setRemoteCallback(function ($search) { $users = User::orderBy('name')->limit(10); foreach (explode(' ', trim($search)) as $word) { $users->where(fn ($query) => $query ->where('name', 'like', "%$word%") ->orWhere('email', 'like', "%$word%") ); } return $users->get(); }) ->setListItemTemplate('