Managing many_to_many with AshPhoenix - works in iex but not in liveview?

I have a resource called Meeting which has a many to many relationship. My create action looks like this:

defmodule Meeting do
  create :create do
    accept [:date]
    change manage_relationship(
        :instances,
         type: :append_and_remove
    )
end

when I create a meeting through iex this works just fine:

MyApp.Meetings.create_meeting(%{date: ~D[2020-01-01], instances: [%{id: 16}]}, actor: u)

# which produces
{:ok,
 %MyApp.Meetings.Meeting{
   id: "33be6aed-3e72-475f-bcdd-5da3749722ad",
   date: ~D[2020-01-01],
   inserted_at: ~U[2025-11-15 21:10:33.992514Z],
   updated_at: ~U[2025-11-15 21:10:33.992514Z],
   is_upcoming?: #Ash.NotLoaded<:calculation, field: :is_upcoming?>,
   actor_is_part_of_meeting?: #Ash.NotLoaded<:calculation, field: :actor_is_part_of_meeting?>,
   users: #Ash.NotLoaded<:relationship, field: :users>,
   instances: [
     %MyApp.Core.Instance{
       id: 16,
       dossier_number: #Ash.NotLoaded<:calculation, field: :dossier_number>,
       __meta__: #Ecto.Schema.Metadata<:loaded, "INSTANCE">
     }
   ],
   users_join_assoc: #Ash.NotLoaded<:relationship, field: :users_join_assoc>,
   instances_join_assoc: #Ash.NotLoaded<:relationship, field: :instances_join_assoc>,
   __meta__: #Ecto.Schema.Metadata<:loaded, "meetings">
 }}

However, when I run the same thing in a liveview:

<.form for={@form} class="uk-form-stacked" phx-submit="submit" phx-change="validate">
<.input field={@form[:date]} label="Datum" type="date" type="date" class="uk-input" />

<div class="uk-flex">
  <div class="uk-flex-1-2">
    <.inputs_for :let={instance} field={@form[:instances]}>
      <!-- <.input field={instance[:dossier_number]} /> -->
      <.input field={instance[:id]} />
    </.inputs_for>

    <label>
      <input
        type="checkbox"
        name={"#{@form.name}[_add_instances]"}
        value="end"
        class="hidden"
      />
      <span class="uk-button uk-button-secondary">
        Neues Dossier hinzufügen
      </span>
    </label>
    -->
  </div>
</div>

<button type="submit" class="uk-button uk-button-primary uk-margin">Speichern</button>
</.form>

with some very vanilla event handlers:

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    form = MyApp.Meetings.form_to_create_meeting(actor: socket.assigns.current_user)

    socket = assign(socket, :form, to_form(form))

    {:ok, socket}
  end

  @impl Phoenix.LiveView
  def handle_event("submit", %{"form" => form}, socket) do
    dbg(form)

    case AshPhoenix.Form.submit(socket.assigns.form,
           params: form,
           actor: socket.assigns.current_user
         ) do
      {:ok, _meeting} ->
        {:noreply, push_navigate(socket, to: ~p"/meetings")}

      {:error, form} ->
        dbg(form)
        {:noreply, assign(socket, :form, form)}
    end
  end

  @impl Phoenix.LiveView
  def handle_event("validate", %{"form" => form}, socket) do
    {:noreply,
     assign(
       socket,
       :form,
       socket.assigns.form
       |> AshPhoenix.Form.validate(form, actor: socket.assigns.current_user)
       |> to_form()
     )}
  end

I get this (it errors out during the creation of the join resource)

[(my_app 0.1.0) lib/my_app_web/live/meetings_new_live.ex:70: MyAppWeb.MeetingsNewLive.handle_event/3]
form #=> %{
  "date" => "1111-01-11",
  "instances" => %{
    "0" => %{
      "_form_type" => "read",
      "_persistent_id" => "0",
      "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
      "dossier_number" => "2024-1"
    }
  }
}

[(my_app 0.1.0) lib/my_app_web/live/meetings_new_live.ex:80: MyAppWeb.MeetingsNewLive.handle_event/3]
form #=> %Phoenix.HTML.Form{
  source: #AshPhoenix.Form<
    resource: MyApp.Meetings.Meeting,
    action: :create,
    type: :create,
    params: %{
      "date" => "1111-01-11",
      "instances" => %{
        "0" => %{
          "_form_type" => "read",
          "_persistent_id" => "0",
          "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
          "dossier_number" => "2024-1"
        }
      }
    },
    source: #Ash.Changeset<
      domain: MyApp.Meetings,
      action_type: :create,
      action: :create,
      attributes: %{date: ~D[1111-01-11]},
      relationships: %{
        instances: [
          {[
             %{
               dossier_number: %{
                 "_form_type" => "read",
                 "_persistent_id" => "0",
                 "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
                 "dossier_number" => "2024-1"
               }
             }
           ],
           [
             debug?: false,
             ignore?: false,
             on_missing: :unrelate,
             on_match: :ignore,
             on_lookup: {:relate, :create, :read, [:dossier_number]},
             on_no_match: :error,
             eager_validate_with: false,
             authorize?: true,
             meta: [id: :instances],
             type: :append_and_remove,
             use_identities: [:dossier_number],
             value_is_key: :dossier_number
           ]}
        ]
      },
      arguments: %{
        instances: [
          %{
            "_form_type" => "read",
            "_persistent_id" => "0",
            "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
            "dossier_number" => "2024-1"
          }
        ]
      },
      errors: [],
      data: %MyApp.Meetings.Meeting{
        id: nil,
        date: nil,
        inserted_at: nil,
        updated_at: nil,
        is_upcoming?: #Ash.NotLoaded<:calculation, field: :is_upcoming?>,
        actor_is_part_of_meeting?: #Ash.NotLoaded<:calculation, field: :actor_is_part_of_meeting?>,
        users: #Ash.NotLoaded<:relationship, field: :users>,
        instances: #Ash.NotLoaded<:relationship, field: :instances>,
        users_join_assoc: #Ash.NotLoaded<:relationship, field: :users_join_assoc>,
        instances_join_assoc: #Ash.NotLoaded<:relationship, field: :instances_join_assoc>,
        __meta__: #Ecto.Schema.Metadata<:built, "meetings">
      },
      valid?: true
    >,
    name: "form",
    data: nil,
    form_keys: [
      instances: [
        data: #Function<25.61500058/1 in AshPhoenix.Form.Auto.relationship_fetcher/3>,
        read_action: :read,
        read_resource: MyApp.Core.Instance,
        type: :list,
        forms: [
          _update: [
            resource: MyApp.Meetings.MeetingInstance,
            managed_relationship: {MyApp.Meetings.Meeting, :instances,
             [
               on_missing: {:unrelate, :destroy},
               type: :append_and_remove,
               on_lookup: {:relate, :create, :read, [:dossier_number]},
               on_no_match: :error,
               on_match: :ignore,
               use_identities: [:dossier_number],
               value_is_key: :dossier_number
             ]},
            type: :single,
            data: %MyApp.Meetings.MeetingInstance{
              id: nil,
              inserted_at: nil,
              updated_at: nil,
              instance_id: nil,
              meeting_id: nil,
              instance: #Ash.NotLoaded<:relationship, field: :instance>,
              meeting: #Ash.NotLoaded<:relationship, field: :meeting>,
              __meta__: #Ecto.Schema.Metadata<:built, "meeting_instances">
            },
            update_action: :create
          ],
          _join: [
            resource: MyApp.Meetings.MeetingInstance,
            managed_relationship: {MyApp.Meetings.Meeting, :instances,
             [
               on_missing: {:unrelate, :destroy},
               type: :append_and_remove,
               on_lookup: {:relate, :create, :read, [:dossier_number]},
               on_no_match: :error,
               on_match: :ignore,
               use_identities: [:dossier_number],
               value_is_key: :dossier_number
             ]},
            type: :single,
            data: #Function<44.61500058/2 in AshPhoenix.Form.Auto.add_join_form/4>,
            destroy_fields: [],
            destroy_action: :destroy,
            merge?: true
          ]
        ],
        sparse?: false,
        managed_relationship: {MyApp.Meetings.Meeting, :instances,
         [
           on_missing: {:unrelate, :destroy},
           type: :append_and_remove,
           on_lookup: {:relate, :create, :read, [:dossier_number]},
           on_no_match: :error,
           on_match: :ignore,
           use_identities: [:dossier_number],
           value_is_key: :dossier_number
         ]},
        must_load?: false
      ]
    ],
    forms: %{
      instances: [
        #AshPhoenix.Form<
          resource: MyApp.Core.Instance,
          action: :read,
          type: :read,
          params: %{
            "_form_type" => "read",
            "_persistent_id" => "0",
            "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
            "dossier_number" => "2024-1"
          },
          source: #Ash.Query<resource: MyApp.Core.Instance, action: :read>,
          name: "form[instances][0]",
          data: nil,
          form_keys: [
            _update: [
              resource: MyApp.Meetings.MeetingInstance,
              managed_relationship: {MyApp.Meetings.Meeting, :instances,
               [
                 on_missing: {:unrelate, :destroy},
                 type: :append_and_remove,
                 on_lookup: {:relate, :create, :read, [:dossier_number]},
                 on_no_match: :error,
                 on_match: :ignore,
                 use_identities: [:dossier_number],
                 value_is_key: :dossier_number
               ]},
              type: :single,
              data: %MyApp.Meetings.MeetingInstance{
                id: nil,
                inserted_at: nil,
                updated_at: nil,
                instance_id: nil,
                meeting_id: nil,
                instance: #Ash.NotLoaded<:relationship, field: :instance>,
                meeting: #Ash.NotLoaded<:relationship, field: :meeting>,
                __meta__: #Ecto.Schema.Metadata<:built, "meeting_instances">
              },
              update_action: :create
            ],
            _join: [
              resource: MyApp.Meetings.MeetingInstance,
              managed_relationship: {MyApp.Meetings.Meeting, :instances,
               [
                 on_missing: {:unrelate, :destroy},
                 type: :append_and_remove,
                 on_lookup: {:relate, :create, :read, [:dossier_number]},
                 on_no_match: :error,
                 on_match: :ignore,
                 use_identities: [:dossier_number],
                 value_is_key: :dossier_number
               ]},
              type: :single,
              data: #Function<44.61500058/2 in AshPhoenix.Form.Auto.add_join_form/4>,
              destroy_fields: [],
              destroy_action: :destroy,
              merge?: true
            ]
          ],
          forms: %{
            _update: #AshPhoenix.Form<
              resource: MyApp.Meetings.MeetingInstance,
              action: :create,
              type: :create,
              params: %{},
              source: #Ash.Changeset<
                domain: MyApp.Meetings,
                action_type: :create,
                action: :create,
                attributes: %{},
                relationships: %{},
                errors: [
                  %Ash.Error.Changes.Required{
                    field: :meeting_id,
                    type: :attribute,
                    resource: MyApp.Meetings.MeetingInstance,
                    splode: Ash.Error,
                    bread_crumbs: [],
                    vars: [],
                    path: [],
                    stacktrace: #Splode.Stacktrace<>,
                    class: :invalid
                  },
                  %Ash.Error.Changes.Required{
                    field: :instance_id,
                    type: :attribute,
                    resource: MyApp.Meetings.MeetingInstance,
                    splode: Ash.Error,
                    bread_crumbs: [],
                    vars: [],
                    path: [],
                    stacktrace: #Splode.Stacktrace<>,
                    class: :invalid
                  }
                ],
                data: %MyApp.Meetings.MeetingInstance{
                  id: nil,
                  inserted_at: nil,
                  updated_at: nil,
                  instance_id: nil,
                  meeting_id: nil,
                  instance: #Ash.NotLoaded<:relationship, field: :instance>,
                  meeting: #Ash.NotLoaded<:relationship, field: :meeting>,
                  __meta__: #Ecto.Schema.Metadata<:built, "meeting_instances">
                },
                context: %{
                  accessing_from: %{
                    name: :instances,
                    source: MyApp.Meetings.Meeting,
                    manage_relationship_opts: [
                      on_missing: {:unrelate, :destroy},
                      type: :append_and_remove,
                      on_lookup: {:relate, :create, :read, [:dossier_number]},
                      on_no_match: :error,
                      on_match: :ignore,
                      use_identities: [:dossier_number],
                      value_is_key: :dossier_number
                    ]
                  }
                },
                valid?: false
              >,
              name: "form[instances][0][_update]",
              data: nil,
              form_keys: [],
              forms: %{},
              domain: MyApp.Meetings,
              method: "post",
              submit_errors: [
                meeting_id: {"is required", []},
                instance_id: {"is required", []}
              ],
              id: "form_instances_0__update",
              transform_errors: nil,
              post_process_errors: nil,
              original_data: nil,
              transform_params: nil,
              prepare_params: nil,
              prepare_source: nil,
              raw_params: %{},
              warn_on_unhandled_errors?: true,
              any_removed?: false,
              added?: false,
              changed?: false,
              touched_forms: MapSet.new([]),
              valid?: false,
              errors: true,
              submitted_once?: true,
              ...
            >
          },
          domain: MyApp.Core,
          method: "post",
          submit_errors: [],
          id: "form_instances_0",
          transform_errors: nil,
          post_process_errors: nil,
          original_data: nil,
          transform_params: nil,
          prepare_params: nil,
          prepare_source: nil,
          raw_params: %{
            "_form_type" => "read",
            "_persistent_id" => "0",
            "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
            "dossier_number" => "2024-1"
          },
          warn_on_unhandled_errors?: true,
          any_removed?: false,
          added?: false,
          changed?: false,
          touched_forms: MapSet.new(["_form_type", "_persistent_id", "_touched",
           "dossier_number"]),
          valid?: false,
          errors: true,
          submitted_once?: true,
          just_submitted?: true,
          ...
        >
      ]
    },
    domain: MyApp.Meetings,
    method: "post",
    submit_errors: [],
    id: "form",
    transform_errors: nil,
    post_process_errors: nil,
    original_data: nil,
    transform_params: nil,
    prepare_params: nil,
    prepare_source: nil,
    raw_params: %{
      "date" => "1111-01-11",
      "instances" => %{
        "0" => %{
          "_form_type" => "read",
          "_persistent_id" => "0",
          "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
          "dossier_number" => "2024-1"
        }
      }
    },
    warn_on_unhandled_errors?: true,
    any_removed?: false,
    added?: false,
    changed?: true,
    touched_forms: MapSet.new(["date", "instances"]),
    valid?: false,
    errors: true,
    submitted_once?: true,
    just_submitted?: true,
    ...
  >,
  impl: Phoenix.HTML.FormData.AshPhoenix.Form,
  id: "form",
  name: "form",
  data: nil,
  action: nil,
  hidden: [_touched: "date,instances", _form_type: "create"],
  params: %{
    "date" => "1111-01-11",
    "instances" => %{
      "0" => %{
        "_form_type" => "read",
        "_persistent_id" => "0",
        "_touched" => "_form_type,_persistent_id,_touched,dossier_number",
        "dossier_number" => "2024-1"
      }
    }
  },
  errors: [],
  options: [method: "post"],
  index: nil
}

I can’t really explain what is happening here. Do I have to clean up these forms somehow? Is this a bug?

I’m sry I can’t figure out how I can edit my original post (I don’t see a button anywhere?).

What particulary confuses me is this bit in the error form:

_update: #AshPhoenix.Form<
              resource: ExEbau.Meetings.MeetingInstance,
              action: :create,
              type: :create,
              params: %{},
              source: #Ash.Changeset<
                domain: ExEbau.Meetings,
                action_type: :create,
                action: :create,
                attributes: %{},
                relationships: %{},
                errors: [
                  %Ash.Error.Changes.Required{
                    field: :meeting_id,
                    type: :attribute,
                    resource: ExEbau.Meetings.MeetingInstance,
                    splode: Ash.Error,
                    bread_crumbs: [],
                    vars: [],
                    path: [],
                    stacktrace: #Splode.Stacktrace<>,
                    class: :invalid
                  },
                  %Ash.Error.Changes.Required{
                    field: :instance_id,
                    type: :attribute,
                    resource: ExEbau.Meetings.MeetingInstance,
                    splode: Ash.Error,
                    bread_crumbs: [],
                    vars: [],
                    path: [],
                    stacktrace: #Splode.Stacktrace<>,
                    class: :invalid
                  }
                ],

Why is it trying to create this relationship now? I can’t know the :meeting_id at this point since it hasn’t been created yet. The instance_id is known and I’m not sure why it’s not passing that to this create action.

I think we need to see more of your resource definition to figure out what’s going on. From what you shared, in the create action, I don’t see an argument to accept :instances. I’m assuming you just elided all the other stuff, including the actions DSL block that your action is inside. :thinking:

Yes, sry that was removed during my cleanup. The resource is too large to post it verbatim.

I kind of figured out a “hack-around”. In my submit I do this:

    # Remove the _update forms that are added by AshPhoenix
    form =
      socket.assigns.form.source.forms.instances
      |> Enum.map(fn instance_form ->
        case Map.get(instance_form, :forms) do
          %{_update: %{name: name}} -> name
          _ -> nil
        end
      end)
      |> Enum.reject(&is_nil(&1))
      |> Enum.reduce(socket.assigns.form, &AshPhoenix.Form.remove_form(&2, &1))

I’m not sure why AshPhoenix seems to add this :_update form and what its good for. But once I delete this it works fine.