Phoenix Walkthrough

Copy Markdown View Source

This walkthrough shows the recommended adoption path for Scrypath in a Phoenix app: searchable schema, context-owned search boundary, controller call, LiveView call, and explicit sync visibility choices.

1. Declare The Searchable Schema

Start with a normal Ecto schema and keep the Scrypath declaration on the schema itself.

defmodule MyApp.Blog.Post do
  use Ecto.Schema

  use Scrypath,
    fields: [:title, :body],
    filterable: [:status],
    sortable: [:inserted_at]

  schema "posts" do
    field :title, :string
    field :body, :string
    field :status, Ecto.Enum, values: [:draft, :published]
    timestamps()
  end
end

That declaration is metadata, not a generated runtime API. The runtime calls still belong in your context.

2. Put Search And Sync In The Context

Put search orchestration in a Phoenix context, not in controllers and not in LiveView modules.

defmodule MyApp.Content do
  alias MyApp.Blog.Post
  alias MyApp.Repo

  def search_posts(query, opts \\ []) do
    Scrypath.search(Post, query,
      Keyword.merge([backend: Scrypath.Meilisearch, repo: Repo], opts)
    )
  end

  def publish_post(post, attrs) do
    with {:ok, post} <- update_post(post, attrs),
         {:ok, _sync} <-
           Scrypath.sync_record(Post, post,
             backend: Scrypath.Meilisearch,
             sync_mode: :inline
           ) do
      {:ok, post}
    end
  end
end

The context owns repo-backed hydration, backend choice, sync mode choice, and preload policy. That is also where you decide whether a write waits inline or returns accepted async work.

3. Call That Boundary From Controllers

Controllers translate request params into a context call and render a response. They should not compose raw Repo and Scrypath.* operations as the recommended pattern.

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller

  alias MyApp.Content

  def index(conn, params) do
    query = Map.get(params, "q", "")

    {:ok, result} =
      Content.search_posts(query,
        filter: [status: "published"]
      )

    render(conn, :index, posts: result.records, search: result)
  end
end

Use the same shape for JSON endpoints. The controller owns request parsing and rendering, while MyApp.Content.search_posts/2 owns repo access and Scrypath options.

4. Reuse The Same Context From LiveView

LiveView owns UI state. The context still owns repo access and Scrypath orchestration.

defmodule MyAppWeb.PostLive do
  use MyAppWeb, :live_view

  alias MyApp.Content

  def mount(_params, _session, socket) do
    {:ok, assign(socket, posts: [], search: nil, query: "")}
  end

  def handle_params(%{"q" => query}, _uri, socket) do
    {:ok, result} = Content.search_posts(query, preload: [:author])

    {:noreply, assign(socket, posts: result.records, search: result, query: query)}
  end
end

That keeps URL state, loading state, and selected filters in the LiveView while keeping the data boundary in the context.

5. Keep Visibility Language Honest

If your context writes to the repo and then enqueues search work, the HTTP response or LiveView update still does not imply the document is searchable yet.

  • :inline means Scrypath waited for terminal backend success
  • :manual means the backend accepted work
  • :oban means the enqueue is durable

Accepted work is not the same thing as search visibility, and none of those modes erase drift, retries, or backend visibility semantics.

Next Guides