Entry Form

To add an entry form to the front-end of your website, create a template with a form that posts to the entries/save-entry controller action. Creating and editing entries requires an active user session, and the corresponding permissions for the target section.

You can accept submissions from logged out or “anonymous” users with the Guest Entries plugin.

This form contains everything you need to get started:

{% macro errorList(errors) %}
  {% if errors %}
    {{ ul(errors, {class: 'errors'}) }}
  {% endif %}
{% endmacro %}

{# If there were any validation errors, an `entry` variable will be 
   passed to the template, which contains the posted values 
   and validation errors. If that’s not set, we’ll default 
   to a new entry. #}
{% set entry = entry ?? create('craft\\elements\\Entry') %}

{# Add `enctype="multipart/form-data"` to `<form>` if you’re 
   uploading files. #}
<form method="post" accept-charset="UTF-8">
  {{ csrfInput() }}
  {{ actionInput('entries/save-entry') }}
  {{ redirectInput('{url}') }}
  {{ hiddenInput('sectionId', 2) }}
  {{ hiddenInput('enabled', true) }}

  <label for="title">Title</label>
  {{ input('text', 'title', entry.title, {
    id: 'title',
  }) }}
  {{ _self.errorList(entry.getErrors('title')) }}

  <label for="body">Body</label>
  {{ tag('textarea', {
    id: 'body',
    name: 'fields[body]',
    text: entry.body,
  }) }}
  {{ _self.errorList(entry.getErrors('body')) }}

  <button type="submit">Publish</button>
</form>

Upon submission, the user will either be dropped back at the form to correct errors, or redirected based on the redirectInput value. In this case, we’ve provided an “object template” that is evaluated after the entry is created, substituting its final/published URL for the {url} placeholder.

If the target section doesn’t have URLs, you can redirect to any static path:

{{ redirectInput('thank-you') }}

Tips #

Section IDs #

Be sure and set the sectionId input’s value to a valid ID for your project. For clarity and reliability, you can look up a section’s ID by its handle, like this:

{% set section = craft.app.sections.getSectionByHandle('mySectionHandle') %}

{{ hiddenInput('sectionId', section.id) }}

Because the ID of a section can be different in each environment, using a handle will help avoid unpredictable bugs when deploying your form!

Custom Fields #

Any custom field handles must be provided under a fields[] key, like body is, above. Keep in mind that some field types use different inputs and names—or may require multiple inputs to work correctly. Refer to the documentation for specifics!

Multi-Site Projects #

If you are working on a multi-site project, you’ll also need to specify a siteId—unless you are only targeting the primary site.

Editing Existing Entries #

To save an existing entry, add a hidden entryId input to the form. This has no effect for new entries, because the ID will be null.

{{ hiddenInput('entryId', entry.id) }}

This alone is enough to update an existing entry from its native page (where you’ll already have access to the entry object)… but things get slightly more complicated if you want to centralize editing tools—say, in a front-end dashboard.

Handling Both New and Existing Entries #

We’ll need two templates to support this:

  • One for creating new entries: templates/_account/new-post.twig
  • One for editing entries: templates/_account/edit-post.twig

The underscores mean this entire directory (_account) is hidden from automatic routing—but you can grant access to them by adding URL rules to routes.php:

return [
    'account/my-posts/new' => ['template' => '_account/new-post'],
    'account/my-posts/<postId:\d+>' => ['template' => '_account/edit-post'],
];

The keys in this array are paths; the values tell Craft to render a template when they are accessed. <postId:\d+> is peculiar, right? This pattern stands in for any series of digits—we’ll use it in a moment to look up an entry!

Extracting the Form #

Both of these routes will need to render a form, so let’s pull that into a separate file, templates/_account/form.twig:

{% macro errorList(errors) %}
  {% if errors %}
    {{ ul(errors, {class: 'errors'}) }}
  {% endif %}
{% endmacro %}

{% set entry = entry ?? create('craft\\elements\\Entry') %}

<form method="post" accept-charset="UTF-8">
  {{ csrfInput() }}
  {{ actionInput('entries/save-entry') }}
  {{ redirectInput(redirect ?? '{url}') }}
  {{ hiddenInput('sectionId', sectionId) }}
  {{ hiddenInput('enabled', true) }}

  {{ hiddenInput('entryId', entry.id) }}

  <label for="title">Title</label>
  {{ input('text', 'title', entry.title, {
    id: 'title',
  }) }}
  {{ _self.errorList(entry.getErrors('title')) }}

  <label for="body">Body</label>
  {{ tag('textarea', {
    id: 'body',
    name: 'fields[body]',
    text: entry.body,
  }) }}
  {{ _self.errorList(entry.getErrors('body')) }}

  <button type="submit">{{ action ?? 'Save' }}</button>
</form>

Notice that we've parameterized a few things: the redirect, the button’s action text, and the actual entry object.

Now, our new-post.twig template can be dramatically simplified:

{{ include('_account/form', {
  redirect: 'account/my-posts/{id}',
  action: 'Create',
  entry: entry ?? null,
}) }}

This will still pass the entry object down when it’s defined in the new-post.twig template (say, after a validation error)—otherwise, it will let the form partial instantiate a blank one. We’re also specifying a redirect to the edit route, rather than its public URL.

The Dynamic Edit Route #

Let’s look at how templates/_account/edit-post.twig will differ. We need to account for the entry object coming from two sources: validation errors (for which Craft passes it back to the template for us); or a lookup based on the URL rule we defined in routes.php.

Craft will automatically inject the postId variable from our parameterized edit rule into the template. Here’s a barebones example of this pattern in action:

{# Check if `entry` is already defined: #}
{% if entry is not defined %}
  {# It isn’t! Let’s look it up based on the `postId` route param: #}
  {% set entry = craft.entries()
    .section('posts')
    .id(postId)
    .one() %}
{% endif %}

{# Let’s make sure we actually found something, or bail: #}
{% if not entry %}
  {% exit 404 %}
{% endif %}

{# Ok, we’re good; render the form: #}
{{ include('_account/form', {
  redirect: 'account/my-posts/{id}',
  action: 'Save',
  entry: entry,
}) }}

Listing a User’s Content #

The above edit route does nothing (on its own) to prevent a user from viewing and modifying another user’s content. Craft will enforce any section permissions upon save, but it’s up to you to prevent content from being displayed that they don't have access to. To start, the lookup based on postId could also include a constraint for the current user:

{% set entry = craft.entries()
  .section('posts')
  .author(currentUser)
  .id(postId)
  .one() %}

Similarly, to display a list of the user’s content on their dashboard, you might perform a query like this:

{% set entries = craft.entries()
  .section('posts')
  .author(currentUser)
  .all() %}

<h1>My Content</h1>

<ul>
  {% for entry in entries %}
    <li>
      {{ entry.title }}
      <a href="{{ entry.url }}">View</a>

      {# The URL here must match our route from earlier: #}
      <a href="{{ url("account/my-posts/#{entry.id}") }}">Edit</a>
    </li>
  {% endfor %}
</ul>

<a href="{{ url('account/my-posts/new') }}">New Post</a>

With that, we’ve built a complete entry management workflow!

Applies to Craft CMS 4 and Craft CMS 3.