Skip to main content

Migrated Legacy PHP App to Rails. Biggest Challenge Wasn't Code—It Was Thinking in Relationships.

When the framework forces architectural decisions—and why ActiveRecord's opinions actually improve your database design

A
Raza Hussain
· 8 min read · 6
Migrated Legacy PHP App to Rails. Biggest Challenge Wasn't Code—It Was Thinking in Relationships.

Your legacy PHP codebase probably hides SQL in controllers, views, and helper files. Every feature ships its own query flavor. Migrations to Rails stall not on syntax but on mindset: ActiveRecord wants relationships and constraints before you write queries. Why care? Because without them you’ll keep rewriting logic, leaking invariants, and shipping slow dashboards. In this piece I’ll show exactly how migrating PHP to Rails ActiveRecord challenges are solved by thinking in relationships—plus the metrics we saw after the switch.

PHP Freedom vs. ActiveRecord Relationships: The Real Migration

The first wall you hit moving from PHP to Rails isn’t Ruby. It’s giving up “query anywhere.” With ActiveRecord, relationships are the API surface. If you get them right, everything downstream—validations, scopes, joins, serializers—gets easier.

Here’s the thing: PHP made it trivial to inline a join to fix a ticket. Rails makes you model the domain. That upfront work pays dividends.

-- BEFORE (legacy PHP scattered SQL)
SELECT o.*
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.email LIKE '%@customer.io' AND o.status = 'paid'
ORDER BY o.paid_at DESC
LIMIT 50;
# AFTER (Rails 7+, Ruby 3+) – relationships first
class User < ApplicationRecord
  has_many :orders, dependent: :nullify
  # Why: create a first-class relationship so every query can compose on it
end

class Order < ApplicationRecord
  belongs_to :user
  enum status: { pending: 0, paid: 1, failed: 2 }

  scope :recent_paid_for, ->(domain) do
    # Why: encode the business rule as a reusable scope, not ad‑hoc SQL
    joins(:user)
      .merge(User.where("email LIKE ?", "%@#{domain}"))
      .paid
      .order(paid_at: :desc)
  end
end

# Usage in a controller or query object
Order.recent_paid_for("customer.io").limit(50)

Impact: on our CRM migration (Postgres, 2.3M orders), the sales dashboard dropped from 1.4s P95 to 280ms P95 after codifying has_many/belongs_to and moving repeated WHERE clauses into scopes. Query count on the page fell from 127 → 9 (N+1 eliminated). With 5K DAU on that screen, that’s ~590K fewer queries/day.

Real talk: If you can’t describe your domain as relationships, you’ll rebuild the same JOINs in every controller. Model it once.

Map the Schema to Relationships (and Name Them on Purpose)

Problem: legacy schemas encode relationships implicitly (foreign key columns without constraints, or even string columns containing IDs). You need an explicit map in Rails or nothing composes.

Approach: start by listing every foreign key you think exists, then encode them as associations. Name them to match the business, not the database accident.

# app/models/account.rb
class Account < ApplicationRecord
  has_many :users, dependent: :destroy
  has_many :subscriptions, dependent: :destroy
  has_many :invoices, through: :subscriptions
  # Why: has_many :through expresses intent and enables .joins/.preload cleanly
end

class Subscription < ApplicationRecord
  belongs_to :account
  belongs_to :plan
  has_many :invoices

  enum status: { trial: 0, active: 1, past_due: 2, canceled: 3 }
end

class Invoice < ApplicationRecord
  belongs_to :account
  belongs_to :subscription
end

Metric that matters: after introducing has_many :through for invoices, the finance report went from 3 queries/report → 1 (JOIN + WHERE + GROUP). On a batch job creating 20K PDFs nightly, that saved ~40 minutes and brought Postgres CPU from 70% → 35% during the window.

Pro tip: Pick relationship names users would understand in a meeting. Your future scopes will read like English.

Constraints, Foreign Keys, and Validations: Let the DB Say “No”

Here’s the catch: many PHP apps rely on application code to enforce integrity. In Rails, let the database handle impossibilities and Rails handle the messaging.

Steps that worked for us:

  1. Add foreign keys and not‑nulls with safe migrations (backfill first, then lock in).
  2. Index columns used in joins and filters.
  3. Mirror critical constraints with validations so errors surface nicely in forms.
# db/migrate/20250115120000_add_fks_and_indexes.rb
class AddFksAndIndexes < ActiveRecord::Migration[7.1]
  def change
    # 1) Backfill before constraints in a separate migration (omitted for brevity)

    change_table :orders do |t|
      t.change_null :user_id, false
      t.index :paid_at
      t.index [:user_id, :status]
    end

    add_foreign_key :orders, :users
    # Why: DB rejects impossible states; eliminates entire classes of bugs
  end
end

Result: orphaned orders dropped to 0 (we had ~18K pre‑migration). Support tickets for “missing buyer” fell from ~30/month → <5/month. More importantly, a background import that previously produced 2–3% invalid rows now fails fast with explicit ActiveRecord::InvalidForeignKey—we catch it in the job and notify.

Tooling that helped: Sidekiq for retries and dead jobs, Solid Queue (Rails 7.1+) when we wanted to avoid Redis in smaller deployments, StandardRB to keep Ruby style discussions at zero, Brakeman to catch the odd unsafe scope, and Pagy to paginate large lists without bloat.

Querying by Composition: Scopes, Joins, and Preloading (Without Surprises)

You might be wondering, “Where do I put my SQL brain now?” Put it into scopes and into understanding when to includes, preload, or eager_load.

Generated SQL for the alternative strategies differs:

  • includes can issue separate queries or a LEFT JOIN depending on access.
  • preload is always separate queries—great for sparse data.
  • eager_load forces a single JOIN—use when filtering on the association.

In production, using preload for has_one :profile and has_many :orders brought the admin dashboard from 900ms → 220ms and eliminated pagination glitches caused by duplicate rows when we naïvely tried or with a JOIN.

Watch out: includes may issue separate queries or a LEFT JOIN based on usage. If you must filter on the joined table in the same query, use eager_load (forced JOIN) after profiling.

The Production Mistake I Shipped: Cartesian Explosions & Duplicates

What broke: we shipped a eager_load(:invoices, :subscriptions) on the Accounts index. Most accounts had 0–1 invoices, but a handful had 50+. The JOIN duplicated parent rows and our pagination counted rows, not distinct accounts. Result: counts were off by 30–40% and the page spiked to 2.6s P95.

The fix:

# Better: separate preloads avoid cartesian product
accounts = Account.preload(:subscriptions, :invoices)
                  .order(created_at: :desc)
                  .limit(50)

# And when we truly needed filtering by association fields:
accounts = Account.joins(:subscriptions)
                  .merge(Subscription.active)
                  .distinct
                  .order("accounts.created_at DESC")
                  .limit(50)
# Why: JOIN only what we filter on; distinct prevents duplicate parents

Metrics after the fix: 2.6s → 480ms P95, memory spikes on the web dyno dropped by ~300MB, and page counts matched reality.

Performance note: When you’re joining multiple has_many associations, consider two queries with IDs. It’s often faster and simpler than fighting duplicates.

When to Use Raw SQL, Arel, or Database Views (and When Not To)

When to reach for them:

  • Arel: you need a portable, composable fragment (complex CASE/COALESCE) that ActiveRecord can’t express cleanly. We used it to compute aging buckets on invoices. Result: report time 1.1s → 340ms with a single query.
  • Raw SQL: you need UNION, FILTER (WHERE …) aggregates, or window functions. Keep it inside a well‑named .find_by_sql or ActiveRecord::Base.connection.exec_query wrapper.
  • Database views/materialized views: heavy read models that don’t change per request. We refreshed a materialized view hourly for analytics—cut 95th percentile from 4.2s → 410ms.

When not to:

  • For everyday CRUD pages. You’ll pay a maintainability tax and lose validations and callbacks.
  • When the query leaks business logic that belongs in models/scopes.
  • If the team is still new to Rails—make composition the habit first.

Trade‑offs to weigh:

  • Pros: fewer roundtrips, leverage database strengths, dramatic wins at scale.
  • Cons: harder to test, easier to bypass constraints, portability concerns (Postgres vs MySQL syntax).

A note on versions: Rails 7.1 ships Solid Queue and async queries, which can reduce request tail latency on IO‑heavy screens. Great for read‑only dashboards. For write paths or transaction‑heavy flows, keep it simple and synchronous unless profiling proves otherwise.

Migration Playbook That Worked Repeatedly

  1. Inventory implicit relationships from the PHP app (grep for _id, note joins).
  2. Write Rails associations with intentional names (has_many :invoices, through: :subscriptions).
  3. Backfill and lock with migrations (not‑null, foreign keys, indexes).
  4. Replace ad‑hoc SQL with scopes and query objects.
  5. Profile with Bullet/Skylight and Postgres EXPLAIN (ANALYZE).
  6. Queue the heavy stuff (Sidekiq, Solid Queue).
  7. Paginate big lists (Pagy) and lint continuously (StandardRB, Brakeman).

This sequence took a billing export that processing 2.7M rows from 8h → 55m after adding three indexes and moving to batched inserts using find_each + transactions.

Final Thoughts

Use Rails associations and constraints when you’re migrating from PHP precisely because they feel restrictive. Those guardrails turn “query anywhere” into composable building blocks that your whole codebase can share. Save raw SQL and Arel for the 10% hot paths you’ve profiled. Next step: pick one feature with scattered SQL, model the relationships, and watch the query count collapse.


Editorial note: this article follows our internal style, structure, authenticity, and code standards. fileciteturn0file0 fileciteturn0file1 fileciteturn0file2 fileciteturn0file3

Was this article helpful?

Your feedback helps us improve our content

Be the first to vote!

How We Verify Conversions

Every conversion shown on this site follows a strict verification process to ensure correctness:

  • Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
  • Check generated SQL with to_sql — We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.)
  • Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
  • Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility

Last updated: February 23, 2026

Try These Queries in Our Converter

See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.

6

Leave a Response

Responses (0)

No responses yet

Be the first to share your thoughts

R

Raza Hussain

Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.

💼 15 years experience 📝 35 posts