There are several reasons for allowing a user to edit in the frontend. For one thing, users feel that working directly on the website is more user-friendly than logging into the backend. Or, it is important for an administrator not to release access to the administration area. Therefore, in the next step, we equip our component with the possibility to edit items in the frontend.

For impatient people: Look at the changed programme code in the Diff View[^codeberg.org/astrid/j4examplecode/compare/t24b...t25] and copy these changes into your development version.

Step by step

New files

administrator/components/com_foos/src/Service/HTML/Icon.php

The following file contains all the information needed to display an icon used to open the edit in the frontend - provided the viewer is allowed to edit.

administrator/components/com_foos/src/Service/HTML/Icon.php

// https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/administrator/components/com_foos/src/Service/HTML/Icon.php

<?php
/**
 * @package     Joomla.Site
 * @subpackage  com_foos
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace FooNamespace\Component\Foos\Administrator\Service\HTML;

use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryInterface;
use FooNamespace\Component\Foos\Site\Helper\RouteHelper;
use Joomla\Registry\Registry;

\defined('_JEXEC') or die;

/**
 * Content Component HTML Helper
 *
 * @since  __BUMP_VERSION__
 */
class Icon
{
    /**
     * The user factory
     *
     * @var    UserFactoryInterface
     *
     * @since  __BUMP_VERSION__
     */
    private $userFactory;

    /**
     * Service constructor
     *
     * @param   UserFactoryInterface  $userFactory  The userFactory
     *
     * @since   __BUMP_VERSION__
     */
    public function __construct(UserFactoryInterface $userFactory)
    {
        $this->userFactory = $userFactory;
    }

    /**
     * Method to generate a link to the create item page for the given category
     *
     * @param   object    $category  The category information
     * @param   Registry  $params    The item parameters
     * @param   array     $attribs   Optional attributes for the link
     *
     * @return  string  The HTML markup for the create item link
     *
     * @since  __BUMP_VERSION__
     */
    public function create($category, $params, $attribs = [])
    {
        $uri = Uri::getInstance();

        $url = 'index.php?option=com_$foo&task=$foo.add&return=' . base64_encode($uri) . '&id=0&catid=' . $category->id;

        $text = '';

        if ($params->get('show_icons')) {
            $text .= '<span class="icon-plus icon-fw" aria-hidden="true"></span>';
        }

        $text .= Text::_('COM_FOOS_NEW_FOOS');

        // Add the button classes to the attribs array
        if (isset($attribs['class'])) {
            $attribs['class'] .= ' btn btn-primary';
        } else {
            $attribs['class'] = 'btn btn-primary';
        }

        $button = HTMLHelper::_('link', Route::_($url), $text, $attribs);

        return $button;
    }

    /**
     * Display an edit icon for the $foo.
     *
     * This icon will not display in a popup window, nor if the $foo is trashed.
     * Edit access checks must be performed in the calling code.
     *
     * @param   object    $foo  The $foo information
     * @param   Registry  $params   The item parameters
     * @param   array     $attribs  Optional attributes for the link
     * @param   boolean   $legacy   True to use legacy images, false to use icomoon based graphic
     *
     * @return  string   The HTML for the $foo edit icon.
     *
     * @since   __BUMP_VERSION__
     */
    public function edit($foo, $params, $attribs = [], $legacy = false)
    {
        $user = Factory::getUser();
        $uri  = Uri::getInstance();

        // Ignore if in a popup window.
        if ($params && $params->get('popup')) {
            return '';
        }

        // Ignore if the state is negative (trashed).
        if ($foo->published < 0) {
            return '';
        }

        // Show checked_out icon if the $foo is checked out by a different user
        if (
            property_exists($foo, 'checked_out')
            && property_exists($foo, 'checked_out_time')
            && !is_null($foo->checked_out)
            && $foo->checked_out !== $user->get('id')
        ) {
            $checkoutUser = $this->userFactory->loadUserById($foo->checked_out);
            $date         = HTMLHelper::_('date', $foo->checked_out_time);
            $tooltip      = Text::sprintf('COM_FOOS_CHECKED_OUT_BY', $checkoutUser->name)
                . ' <br> ' . $date;

            $text = LayoutHelper::render('joomla.content.icons.edit_lock', ['$foo' => $foo, 'tooltip' => $tooltip, 'legacy' => $legacy]);

            $attribs['aria-describedby'] = 'edit$foo-' . (int) $foo->id;
            $output                      = HTMLHelper::_('link', '#', $text, $attribs);

            return $output;
        }
		
		if (!isset($foo->slug)) {
			$foo->slug = "";
		}

        $fooUrl = RouteHelper::getFooRoute($foo->slug, $foo->catid, $foo->language);
        $url = $fooUrl . '&task=$foo.edit&id=' . $foo->id . '&return=' . base64_encode($uri);

        if ((int) $foo->published === 0) {
            $tooltip = Text::_('COM_FOOS_EDIT_UNPUBLISHED_FOOS');
        } else {
            $tooltip = Text::_('COM_FOOS_EDIT_PUBLISHED_FOOS');
        }

        $nowDate = strtotime(Factory::getDate());
        $icon    = $foo->published ? 'edit' : 'eye-slash';

        if (
            ($foo->publish_up !== null && strtotime($foo->publish_up) > $nowDate)
            || ($foo->publish_down !== null && strtotime($foo->publish_down) < $nowDate)
        ) {
            $icon = 'eye-slash';
        }

        $aria_described = 'edit$foo-' . (int) $foo->id;

        $text = '<span class="icon-' . $icon . '" aria-hidden="true"></span>';
        $text .= Text::_('JGLOBAL_EDIT');
        $text .= '<div role="tooltip" id="' . $aria_described . '">' . $tooltip . '</div>';

        $attribs['aria-describedby'] = $aria_described;
        $output                      = HTMLHelper::_('link', Route::_($url), $text, $attribs);

        return $output;
    }
}

components/com_foos/forms/foo.xml

We adapt the XML file that Joomla uses to build the form.

components/com_foos/forms/foo.xml

<!-- https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/forms/foo.xml -->

<?xml version="1.0" encoding="utf-8"?>
<form>
	<fieldset 
		addruleprefix="FooNamespace\Component\Foos\Administrator\Rule"
		addfieldprefix="FooNamespace\Component\Foos\Administrator\Field"
	>
		<field
			name="id"
			type="number"
			label="JGLOBAL_FIELD_ID_LABEL"
			default="0"
			class="readonly"
			readonly="true"
		/>

		<field
			name="name"
			type="text"
			validate="Letter"
			class="validate-letter"
			label="COM_FOOS_FIELD_NAME_LABEL"
			size="40"
			required="true"
		 />

		<field
			name="alias"
			type="text"
			label="JFIELD_ALIAS_LABEL"
			size="45"
			hint="JFIELD_ALIAS_PLACEHOLDER"
		/>
	</fieldset>
	<fieldset name="language" label="JFIELD_LANGUAGE_LABEL">
		<field
			name="language"
			type="contentlanguage"
			label="JFIELD_LANGUAGE_LABEL"
			>
			<option value="*">JALL</option>
		</field>
	</fieldset>
	<fieldset name="publishing" label="JGLOBAL_FIELDSET_PUBLISHING">
		<field
			name="featured"
			type="list"
			label="JFEATURED"
			default="0"
			validate="options"
		>
			<option value="0">JNO</option>
			<option value="1">JYES</option>
		</field>

		<field
			name="published"
			type="list"
			label="JSTATUS"
			default="1"
			id="published"
			class="custom-select-color-state"
			size="1"
			>
			<option value="1">JPUBLISHED</option>
			<option value="0">JUNPUBLISHED</option>
			<option value="2">JARCHIVED</option>
			<option value="-2">JTRASHED</option>
		</field>

		<field
			name="publish_up"
			type="calendar"
			label="COM_FOOS_FIELD_PUBLISH_UP_LABEL"
			translateformat="true"
			showtime="true"
			size="22"
			filter="user_utc"
		/>

		<field
			name="publish_down"
			type="calendar"
			label="COM_FOOS_FIELD_PUBLISH_DOWN_LABEL"
			translateformat="true"
			showtime="true"
			size="22"
			filter="user_utc"
		/>

		<field
			name="catid"
			type="categoryedit"
			label="JCATEGORY"
			extension="com_foos"
			addfieldprefix="Joomla\Component\Categories\Administrator\Field"
			required="true"
			default=""
		/>

		<field
			name="access"
			type="accesslevel"
			label="JFIELD_ACCESS_LABEL"
			size="1"
		/>

		<field
			name="checked_out"
			type="hidden"
			filter="unset"
		/>

		<field
			name="checked_out_time"
			type="hidden"
			filter="unset"
		/>
	</fieldset>
	<fields name="params" label="JGLOBAL_FIELDSET_DISPLAY_OPTIONS">
		<fieldset name="display" label="JGLOBAL_FIELDSET_DISPLAY_OPTIONS">
			<field
				name="show_name"
				type="list"
				label="COM_FOOS_FIELD_PARAMS_NAME_LABEL"
				useglobal="true"
			>
				<option value="0">JHIDE</option>
				<option value="1">JSHOW</option>
			</field>

			<field
				name="foos_layout"
				type="componentlayout"
				label="JFIELD_ALT_LAYOUT_LABEL"
				class="custom-select"
				extension="com_foos"
				view="foo"
				useglobal="true"
			/>
		</fieldset>
	</fields>
</form>

components/com_foos/src/Controller/FooController.php

The file components/com_foos/src/Controller/FooController.php contains the logic for processing in the form.

Note the function save. This is not usual in the FormController, because Joomla takes care of everything for you. Since the ID is first created when an element is created and is therefore not known, Joomla forwards to the overview page after creation. We have not yet created this in the frontend. That is why I have changed this function here.

components/com_foos/src/Controller/FooController.php

// https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/src/Controller/FooController.php

<?php
/**
 * @package     Joomla.Site
 * @subpackage  com_foos
 *
 * @copyright   Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace FooNamespace\Component\Foos\Site\Controller;

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\Utilities\ArrayHelper;

/**
 * Controller for single foo view
 *
 * @since  __DEPLOY_VERSION__
 */
class FooController extends FormController
{
	/**
	 * The URL view item variable.
	 *
	 * @var    string
	 * @since  __DEPLOY_VERSION__
	 */
	protected $view_item = 'form';

	/**
	 * Method to get a model object, loading it if required.
	 *
	 * @param   string  $name    The model name. Optional.
	 * @param   string  $prefix  The class prefix. Optional.
	 * @param   array   $config  Configuration array for model. Optional.
	 *
	 * @return  \Joomla\CMS\MVC\Model\BaseDatabaseModel  The model.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function getModel($name = 'form', $prefix = '', $config = ['ignore_request' => true])
	{
		return parent::getModel($name, $prefix, ['ignore_request' => false]);
	}

	/**
	 * Method override to check if you can add a new record.
	 *
	 * @param   array  $data  An array of input data.
	 *
	 * @return  boolean
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function allowAdd($data = [])
	{
		if ($categoryId = ArrayHelper::getValue($data, 'catid', $this->input->getInt('catid'), 'int')) {
			$user = Factory::getUser();

			// If the category has been passed in the data or URL check it.
			return $user->authorise('core.create', 'com_foos.category.' . $categoryId);
		}

		// In the absence of better information, revert to the component permissions.
		return parent::allowAdd();
	}

	/**
	 * Method override to check if you can edit an existing record.
	 *
	 * @param   array   $data  An array of input data.
	 * @param   string  $key   The name of the key for the primary key; default is id.
	 *
	 * @return  boolean
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function allowEdit($data = [], $key = 'id')
	{
		$recordId = (int) isset($data[$key]) ? $data[$key] : 0;

		if (!$recordId) {
			return false;
		}

		// Need to do a lookup from the model.
		$record     = $this->getModel()->getItem($recordId);
		$categoryId = (int) $record->catid;

		if ($categoryId) {
			$user = Factory::getUser();

			// The category has been set. Check the category permissions.
			if ($user->authorise('core.edit', $this->option . '.category.' . $categoryId)) {
				return true;
			}

			// Fallback on edit.own.
			if ($user->authorise('core.edit.own', $this->option . '.category.' . $categoryId)) {
				return ($record->created_by == $user->id);
			}

			return false;
		}

		// Since there is no asset tracking, revert to the component permissions.
		return parent::allowEdit($data, $key);
	}

	/**
	 * Method to save a record.
	 *
	 * @param   string  $key     The name of the primary key of the URL variable.
	 * @param   string  $urlVar  The name of the URL variable if different from the primary key (sometimes required to avoid router collisions).
	 *
	 * @return  boolean  True if successful, false otherwise.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function save($key = null, $urlVar = null)
	{
		$result = parent::save($key, $urlVar = null);

		$this->setRedirect(Route::_($this->getReturnPage(), false));

		return $result;
	}

	/**
	 * Method to cancel an edit.
	 *
	 * @param   string  $key  The name of the primary key of the URL variable.
	 *
	 * @return  boolean  True if access level checks pass, false otherwise.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function cancel($key = null)
	{
		$result = parent::cancel($key);

		$this->setRedirect(Route::_($this->getReturnPage(), false));

		return $result;
	}

	/**
	 * Gets the URL arguments to append to an item redirect.
	 *
	 * @param   integer  $recordId  The primary key id for the item.
	 * @param   string   $urlVar    The name of the URL variable for the id.
	 *
	 * @return  string    The arguments to append to the redirect URL.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function getRedirectToItemAppend($recordId = 0, $urlVar = 'id')
	{
		// Need to override the parent method completely.
		$tmpl = $this->input->get('tmpl');

		$append = '';

		// Setup redirect info.
		if ($tmpl) {
			$append .= '&tmpl=' . $tmpl;
		}

		$append .= '&layout=edit';

		$append .= '&' . $urlVar . '=' . (int) $recordId;

		$itemId = $this->input->getInt('Itemid');
		$return = $this->getReturnPage();
		$catId  = $this->input->getInt('catid');

		if ($itemId) {
			$append .= '&Itemid=' . $itemId;
		}

		if ($catId) {
			$append .= '&catid=' . $catId;
		}

		if ($return) {
			$append .= '&return=' . base64_encode($return);
		}

		return $append;
	}

	/**
	 * Get the return URL.
	 *
	 * If a "return" variable has been passed in the request
	 *
	 * @return  string    The return URL.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function getReturnPage()
	{
		$return = $this->input->get('return', null, 'base64');

		if (empty($return) || !Uri::isInternal(base64_decode($return))) {
			return Uri::base();
		}

		return base64_decode($return);
	}
}

components/com_foos/src/Model/FormModel.php

The file components/com_foos/src/Model/FormModel.php organises all the necessary data for processing in the form.

components/com_foos/src/Model/FormModel.php

// https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/src/Model/FormModel.php

<?php
/**
 * @package     Joomla.Site
 * @subpackage  com_foos
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace FooNamespace\Component\Foos\Site\Model;

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Language\Associations;
use Joomla\CMS\Language\Multilanguage;
use Joomla\Registry\Registry;
use Joomla\Utilities\ArrayHelper;

/**
 * Foo Component Foo Model
 *
 * @since  __DEPLOY_VERSION__
 */
class FormModel extends \FooNamespace\Component\Foos\Administrator\Model\FooModel
{
	/**
	 * Model typeAlias string. Used for version history.
	 *
	 * @var  string
	 * @since  __DEPLOY_VERSION__
	 */
	public $typeAlias = 'com_foos.foo';

	/**
	 * Name of the form
	 *
	 * @var string
	 * @since  __DEPLOY_VERSION__
	 */
	protected $formName = 'form';

	/**
	 * Method to get the row form.
	 *
	 * @param   array    $data      Data for the form.
	 * @param   boolean  $loadData  True if the form is to load its own data (default case), false if not.
	 *
	 * @return  \JForm|boolean  A \JForm object on success, false on failure
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function getForm($data = [], $loadData = true)
	{
		$form = parent::getForm($data, $loadData);

		// Prevent messing with article language and category when editing existing foo with associations
		if ($id = $this->getState('foo.id') && Associations::isEnabled()) {
			$associations = Associations::getAssociations('com_foos', '#__foos_details', 'com_foos.item', $id);

			// Make fields read only
			if (!empty($associations)) {
				$form->setFieldAttribute('language', 'readonly', 'true');
				$form->setFieldAttribute('language', 'filter', 'unset');
			}
		}

		return $form;
	}

	/**
	 * Method to get foo data.
	 *
	 * @param   integer  $itemId  The id of the foo.
	 *
	 * @return  mixed  Foo item data object on success, false on failure.
	 *
	 * @throws  Exception
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function getItem($itemId = null)
	{
		$itemId = (int) (!empty($itemId)) ? $itemId : $this->getState('foo.id');

		// Get a row instance.
		$table = $this->getTable();

		// Attempt to load the row.
		try {
			if (!$table->load($itemId)) {
				return false;
			}
		} catch (Exception $e) {
			Factory::getApplication()->enqueueMessage($e->getMessage());

			return false;
		}

		$properties = $table->getProperties();
		$value      = ArrayHelper::toObject($properties, 'JObject');

		// Convert field to Registry.
		$value->params = new Registry($value->params);

		return $value;
	}

	/**
	 * Get the return URL.
	 *
	 * @return  string  The return URL.
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	public function getReturnPage()
	{
		return base64_encode($this->getState('return_page'));
	}

	/**
	 * Method to save the form data.
	 *
	 * @param   array  $data  The form data.
	 *
	 * @return  boolean  True on success.
	 *
	 * @throws Exception
	 * @since   __DEPLOY_VERSION__
	 */
	public function save($data)
	{
		// Associations are not edited in frontend ATM so we have to inherit them
		if (Associations::isEnabled() && !empty($data['id'])
			&& $associations = Associations::getAssociations('com_foos', '#__foos_details', 'com_foos.item', $data['id'])) {
			foreach ($associations as $tag => $associated) {
				$associations[$tag] = (int) $associated->id;
			}

			$data['associations'] = $associations;
		}

		return parent::save($data);
	}

	/**
	 * Method to auto-populate the model state.
	 *
	 * Note. Calling getState in this method will result in recursion.
	 *
	 * @return  void
	 *
	 * @throws  Exception
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function populateState()
	{
		$app = Factory::getApplication();
		$input = $app->getInput();

		$pk = $input->getInt('id');
		$this->setState('foo.id', $pk);

		$this->setState('foo.catid', $input->getInt('catid'));

		$return = $input->get('return', '', 'base64');
		$this->setState('return_page', base64_decode($return));

		// Load the parameters.
		$params = $app->getParams();
		$this->setState('params', $params);

		$this->setState('layout', $input->getString('layout'));
	}

	/**
	 * Allows preprocessing of the JForm object.
	 *
	 * @param   Form    $form   The form object
	 * @param   array   $data   The data to be merged into the form object
	 * @param   string  $group  The plugin group to be executed
	 *
	 * @return  Form
	 *
	 * @since   __DEPLOY_VERSION__
	 */
	protected function preprocessForm(Form $form, $data, $group = 'foo')
	{
		if (!Multilanguage::isEnabled()) {
			$form->setFieldAttribute('language', 'type', 'hidden');
			$form->setFieldAttribute('language', 'default', '*');
		}

		return parent::preprocessForm($form, $data, $group);
	}

	/**
	 * Method to get a table object, load it if necessary.
	 *
	 * @param   string  $name     The table name. Optional.
	 * @param   string  $prefix   The class prefix. Optional.
	 * @param   array   $options  Configuration array for model. Optional.
	 *
	 * @return  Table  A Table object
	 *
	 * @since   __DEPLOY_VERSION__
	 * @throws  \Exception
	 */
	public function getTable($name = 'Foo', $prefix = 'Administrator', $options = [])
	{
		return parent::getTable($name, $prefix, $options);
	}
}

components/com_foos/src/View/Form/HtmlView.php

The file components/com_foos/src/View/Form/HtmlView.php fetches all the necessary data and passes it on to the template file edit.php.

components/com_foos/src/View/Form/HtmlView.php

// https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/src/View/Form/HtmlView.php

<?php
/**
 * @package     Joomla.Site
 * @subpackage  com_foos
 *
 * @copyright   Copyright (C) 2005 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace FooNamespace\Component\Foos\Site\View\Form;

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Multilanguage;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use FooNamespace\Component\Foos\Administrator\Helper\FooHelper;

/**
 * HTML Foo View class for the Foo component
 *
 * @since  __DEPLOY_VERSION__
 */
class HtmlView extends BaseHtmlView
{
	/**
	 * @var    \Joomla\CMS\Form\Form
	 * @since  __DEPLOY_VERSION__
	 */
	protected $form;

	/**
	 * @var    object
	 * @since  __DEPLOY_VERSION__
	 */
	protected $item;

	/**
	 * @var    string
	 * @since  __DEPLOY_VERSION__
	 */
	protected $return_page;

	/**
	 * @var    string
	 * @since  __DEPLOY_VERSION__
	 */
	protected $pageclass_sfx;

	/**
	 * @var    \Joomla\Registry\Registry
	 * @since  __DEPLOY_VERSION__
	 */
	protected $state;

	/**
	 * @var    \Joomla\Registry\Registry
	 * @since  __DEPLOY_VERSION__
	 */
	protected $params;

	/**
	 * Execute and display a template script.
	 *
	 * @param   string  $tpl  The name of the template file to parse; automatically searches through the template paths.
	 *
	 * @return  mixed  A string if successful, otherwise an Error object.
	 *
	 * @throws Exception
	 * @since  __DEPLOY_VERSION__
	 */
	public function display($tpl = null)
	{
		$user = Factory::getUser();
		$app  = Factory::getApplication();

		// Get model data.
		$this->state = $this->get('State');
		$this->item = $this->get('Item');
		$this->form = $this->get('Form');
		$this->return_page = $this->get('ReturnPage');

		if (empty($this->item->id)) {
			$authorised = $user->authorise('core.create', 'com_foos') || count($user->getAuthorisedCategories('com_foos', 'core.create'));
		} else {
			// Since we don't track these assets at the item level, use the category id.
			$canDo = FooHelper::getActions('com_foos', 'category', $this->item->catid);
			$authorised = $canDo->get('core.edit') || ($canDo->get('core.edit.own') && $this->item->created_by == $user->id);
		}

		if ($authorised !== true) {
			$app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
			$app->setHeader('status', 403, true);

			return false;
		}

		// Check for errors.
		if (count($errors = $this->get('Errors'))) {
			$app->enqueueMessage(implode("\n", $errors), 'error');

			return false;
		}

		// Create a shortcut to the parameters.
		$this->params = $this->state->params;

		// Escape strings for HTML output
        $this->pageclass_sfx = htmlspecialchars($this->params->get('pageclass_sfx', ''));

		// Override global params with foo specific params
		$this->params->merge($this->item->params);

		// Propose current language as default when creating new foo
		if (empty($this->item->id) && Multilanguage::isEnabled()) {
			$lang = Factory::getLanguage()->getTag();
			$this->form->setFieldAttribute('language', 'default', $lang);
		}

		$this->_prepareDocument();

		parent::display($tpl);
	}

	/**
	 * Prepares the document
	 *
	 * @return  void
	 *
	 * @throws Exception
	 *
	 * @since  __DEPLOY_VERSION__
	 */
	protected function _prepareDocument()
	{
		$app   = Factory::getApplication();
		$menus = $app->getMenu();
		$title = null;

		// Because the application sets a default page title,
		// we need to get it from the menu item itself
		$menu = $menus->getActive();

		if ($menu) {
			$this->params->def('page_heading', $this->params->get('page_title', $menu->title));
		} else {
			$this->params->def('page_heading', Text::_('COM_FOOS_FORM_EDIT_FOO'));
		}

		$title = $this->params->def('page_title', Text::_('COM_FOOS_FORM_EDIT_FOO'));

		if ($app->get('sitename_pagetitles', 0) == 1) {
			$title = Text::sprintf('JPAGETITLE', $app->get('sitename'), $title);
		} else if ($app->get('sitename_pagetitles', 0) == 2) {
			$title = Text::sprintf('JPAGETITLE', $title, $app->get('sitename'));
		}

		$this->document->setTitle($title);

		$pathway = $app->getPathWay();
		$pathway->addItem($title, '');
	}
}

In the code example above, I have used the code in Joomla as a guide when checking the permissions. If someone is not authorised, a message is displayed. Depending on the environment in which the extension is programmed, it is more user-friendly to offer a login option immediately. In this case: Place in the file components/com_foos/src/View/Form/HtmlView.php the following code excerpt

		if ($authorised !== true) {
			$app->redirect('index.php?option=com_users&view=login');
		}

instead of this

		if ($authorised !== true)
		{
			$app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
			$app->setHeader('status', 403, true);

			return false;
		}

If the authorisation check fails, you are immediately redirected to the registration form.

components/com_foos/tmpl/form/edit.php

As a template, components/com_foos/tmpl/form/edit.php ensures that the form is already displayed in the frontend.

components/com_foos/tmpl/form/edit.php

// https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/tmpl/form/edit.php

<?php
/**
 * @package     Joomla.Administrator
 * @subpackage  com_foos
 *
 * @copyright   Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Multilanguage;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Associations;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;

HTMLHelper::_('behavior.keepalive');
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('script', 'com_foos/admin-foos-letter.js', ['version' => 'auto', 'relative' => true]);

$this->tab_name  = 'com-foos-form';
$this->ignore_fieldsets = ['details', 'item_associations', 'language'];
$this->useCoreUI = true;
?>
<form action="<?php echo Route::_('index.php?option=com_foos&id=' . (int) $this->item->id); ?>" method="post" name="adminForm" id="adminForm" class="form-validate form-vertical">
	<fieldset>
		<?php echo HTMLHelper::_('uitab.startTabSet', $this->tab_name, ['active' => 'details']); ?>
		<?php echo HTMLHelper::_('uitab.addTab', $this->tab_name, 'details', empty($this->item->id) ? Text::_('COM_FOOS_NEW_FOO') : Text::_('COM_FOOS_EDIT_FOO')); ?>
		<?php echo $this->form->renderField('name'); ?>

		<?php if (is_null($this->item->id)) : ?>
			<?php echo $this->form->renderField('alias'); ?>
		<?php endif; ?>
		<?php echo $this->form->renderFieldset('details'); ?>
		<?php echo HTMLHelper::_('uitab.endTab'); ?>
		
		<?php if (Multilanguage::isEnabled()) : ?>
				<?php echo HTMLHelper::_('uitab.addTab', $this->tab_name, 'language', Text::_('JFIELD_LANGUAGE_LABEL')); ?>
				<?php echo $this->form->renderField('language'); ?>
				<?php echo HTMLHelper::_('uitab.endTab'); ?>
		<?php else : ?>
				<?php echo $this->form->renderField('language'); ?>
		<?php endif; ?>
		
		<?php echo LayoutHelper::render('joomla.edit.params', $this); ?>
		<?php echo HTMLHelper::_('uitab.endTabSet'); ?>

		<input type="hidden" name="task" value=""/>
		<input type="hidden" name="return" value="<?php echo $this->return_page; ?>"/>
		<?php echo HTMLHelper::_('form.token'); ?>
	</fieldset>
	<div class="mb-2">
		<button type="button" class="btn btn-primary" onclick="Joomla.submitbutton('foo.save')">
			<span class="fas fa-check" aria-hidden="true"></span>
			<?php echo Text::_('JSAVE'); ?>
		</button>
		<button type="button" class="btn btn-danger" onclick="Joomla.submitbutton('foo.cancel')">
			<span class="fas fa-times-cancel" aria-hidden="true"></span>
			<?php echo Text::_('JCANCEL'); ?>
		</button>
	</div>
</form>

components/com_foos/tmpl/form/edit.xml

Last but not least we need the file components/com_foos/tmpl/form/edit.xml to create the menu item.

components/com_foos/tmpl/form/edit.xml

<!-- https://codeberg.org/astrid/j4examplecode/raw/branch/t25/src/components/com_foos/tmpl/form/edit.xml -->

<?xml version="1.0" encoding="utf-8"?>
<metadata>
	<layout title="COM_FOOS_FORM_VIEW_DEFAULT_TITLE">
		<help
			key="JHELP_MENUS_MENU_ITEM_FOO_CREATE"
		/>
		<message>
			<![CDATA[COM_FOOS_FORM_VIEW_DEFAULT_DESC]]>
		</message>
	</layout>
	<fields name="params">

	</fields>
</metadata>

Modified files

administrator/components/com_foos/src/Extension/FoosComponent.php

In the file administrator/components/com_foos/src/Extension/FoosComponent.php we register the icon. In other words, we make the icon known to Joomla.

administrator/components/com_foos/src/Extension/FoosComponent.php

 defined('JPATH_PLATFORM') or die;

use Joomla\CMS\Application\UserFactoryInterface;
 use Joomla\CMS\Association\AssociationServiceInterface;
 use Joomla\CMS\Association\AssociationServiceTrait;
 use Joomla\CMS\Categories\CategoryServiceInterface;

 use Joomla\CMS\Extension\MVCComponent;
 use Joomla\CMS\HTML\HTMLRegistryAwareTrait;
 use FooNamespace\Component\Foos\Administrator\Service\HTML\AdministratorService;
use FooNamespace\Component\Foos\Administrator\Service\HTML\Icon;
 use Psr\Container\ContainerInterface;
 use Joomla\CMS\Helper\ContentHelper;


 	public function boot(ContainerInterface $container)
 	{
 		$this->getRegistry()->register('foosadministrator', new AdministratorService);
		$this->getRegistry()->register('fooicon', new Icon($container->get(UserFactoryInterface::class)));
 	}

 	/**

components/com_foos/tmpl/foo/default.php

We extend the template for the view: If you are allowed to edit the element if ($canEdit), then you see the icon to open the form.

components/com_foos/tmpl/foo/default.php

  */
 \defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Helper\ContentHelper;
 use Joomla\CMS\Language\Text;

if ($this->item->params->get('show_name')) {
$canDo   = ContentHelper::getActions('com_foos', 'category', $this->item->catid);
$canEdit = $canDo->get('core.edit') || ($canDo->get('core.edit.own') && $this->item->created_by == Factory::getUser()->id);
$tparams = $this->item->params;

if ($tparams->get('show_name')) {
 	if ($this->params->get('show_foo_name_label')) {
 		echo Text::_('COM_FOOS_NAME');
 	}

 	echo $this->item->name;
 }
?>

<?php if ($canEdit) : ?>
	<div class="icons">
		<div class="btn-group float-right">
			<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton-<?php echo $this->item->id; ?>"
				aria-label="<?php echo JText::_('JUSER_TOOLS'); ?>"
				data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
				<span class="fa fa-cog" aria-hidden="true"></span>
			</button>
			<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton-<?php echo $this->item->id; ?>">
				<li class="edit-icon"> <?php echo JHtml::_('fooicon.edit', $this->item, $tparams); ?> </li>
			</ul>
		</div>
	</div>
<?php endif; ?>

<?php
 echo $this->item->event->afterDisplayTitle;
 echo $this->item->event->beforeDisplayContent;
 echo $this->item->event->afterDisplayContent;

Tip: Do you want a user to be redirected to the finished view of an item after it has been created? This is only possible in a indirect way. Because you don't know the ID when you create it, you have to ask for it. Since we extend the model classes of Joomla-Core, we can access the ID via the model in the postSaveHook() method of the controller. Concretely, in the file src/components/com_foos/src/Controller/FooController.php the following code could be used to set up the redirection:

...
protected function postSaveHook(\Joomla\CMS\MVC\Model\BaseDatabaseModel $model, $validData = [])
{
	$id = $model->getState($model->getName() . '.id');
	$this->setRedirect(Route::_('index.php?option=com_agosms&view=foo&id=' . $id, false));
	return $id;
}
...

Test your Joomla component

  1. install your component in Joomla version 4 to test it:

Copy the files in the administrator folder into the administrator folder of your Joomla 4 installation.
Copy the files in the components folder into the components folder of your Joomla 4 installation.

Install your component as described in part one, after you have copied all the files. Joomla will update the namespaces for you during the installation. Since a new file has been added, this is necessary.

  1. Create a menu item to change a Foo element and one that displays a Foo element.

Joomla Frontend Editing Menu Item to Create a Foo Element

  1. Open the menu item to create a Foo element in the frontend. Make sure you have the necessary rights. If you have left the default rights, you must log in with a user who is at least an author. Make sure that you can create an element.

Joomla Frontend Editing - Creating a Foo Element

  1. Make sure that you see the edit icon in the detail view of an element and that an element is editable.

Joomla Frontend Editing - Editing a Foo Element

Adding frontend edit for com_contact[^github.com/joomla/joomla-cms/pull/24311]