1. Introduction
I have been a PHP & Laravel Developer since I wrote my first lines of code almost 2 decades ago. I always loved doing things for the web, share it, and see it online. Languages like C++, Java, Python never really appealed to me because they were mostly used for backend or visual “offline” projects. I wanted to build for the web, for people to see and use.
If you are reading this and you are old enough, you know how good it was to mix and match PHP on top of your templates & files, interpolate variables, and just call it a day. HTML was there for you, SIMPLE! I miss those days honestly! And it was totally “safe” cough cough. 😅
As we move on over the years we start to take care of more things: SQL injection, XSS, CSRF. We continue our journey to be more secure, more performant, more maintainable, more scalable. The list goes on.
Then we start to see “frameworks” like Yii, CodeIgniter, Symfony, Laravel, etc. which were more “modular” and “opinionated” but still had a lot of flexibility and control over the code. This way we could benefit from their already done functionalities, but still have the ability to customize and extend them to our own needs. How amazing is that?
<?php
$title = 'Hello World';
$description = 'This is a description';
$items = mysqli_query($conn, "SELECT * FROM items");
?>
<div>
<h1>{$title}</h1>
<p>{$description}</p>
<ul>
<?php foreach ($items as $item): ?>
<li>{$item['name']}</li>
<?php endforeach; ?>
</ul>
</div>
2. PHP + Laravel Golden Age
There was a time when Laravel offered the BEST developer experience you could find (and still does!). Why? Because we had little to no JavaScript on our projects. Things just worked.
We’d use jQuery + Bootstrap, render Blade templates, make a few Ajax calls, and call it a day. Database, Queues, Events. Everything in ONE language and ONE framework. No mental overhead, no context switching, no learning multiple ecosystems. Just PHP and Laravel.
We might have peaked here.
// Routes
Route::get('/', [ItemController::class, 'index']);
// Controller
public function index()
{
$title = 'Hello World';
$description = 'This is a description';
$items = Item::all();
return view('index', compact('title', 'items'));
}
<div>
<h1>{{ $title }}</h1>
<p>{{ $description }}</p>
<ul>
@foreach ($items as $item)
<li>{{ $item['name'] }}</li>
@endforeach
</ul>
</div>
3. The Rise of Javascript
But the world never stops. For good or bad, we got pushed into doing MORE on the client side. More JavaScript! Interactive websites became the “standard”. While jQuery lasted a long time, frameworks like React, Vue, and Angular took over the frontend world. The reasoning? Browsers could handle the heavy rendering, freeing up server resources for more requests.
This brought the era of APIs: JSON, GraphQL, REST. Now you needed to write API endpoints to serve data to different clients, not just browsers. Developers split into camps. Backend folks handled APIs and business logic, while Frontend devs crafted interactive UIs, real-time dashboards, and modern web applications.
JavaScript was still manageable at this point. We had task runners like Gulp and Grunt to bundle, compress, and minify our files into single or multiple outputs. Simple enough.
But as you’ll see in the next section, things were about to get way more complicated. 😬
// Gulp file example
gulp.task('build', function() {
return gulp.src('src/**/*.js')
.pipe(gulp.dest('dist'))
.pipe(uglify())
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest('dist'));
});
4. Frontend Complexity with Javascript
Well, you think everything was wonderful, right? 😅 Not really! Engineers always find ways to complicate things (job security, am I right?).
With the rise of JavaScript came a tsunami of complexity. We needed to figure out bundling, optimization, rendering strategies, and package distribution. Enter Webpack, Rollup, Parcel, and later Vite. And with them? The legendary node_modules folder! The heaviest object in the universe, deeper than the Mariana Trench! 🌊
But why all this complexity? Gulp and Grunt were just task runners that merged and minified files. Modern bundlers are smarter (and way more complicated): code splitting, tree shaking, dead code elimination, minification, source maps, hot module replacement. The list goes on and on and on.
And don’t forget the browser wars! You had to support Chrome, Firefox, Safari, and god forbid… IE11. Your code needed transpiling (hello Babel!), polyfills for older browsers, and different builds for different targets. What worked in Chrome might break in Safari. Fun times!
The build process went from “save and refresh” to “save, wait 30 seconds for Webpack to rebuild, pray it doesn’t error, then refresh.” The DX we had with PHP’s instant feedback? Gone. Welcome to the world of build times and configuration hell.
Webpack configs became 500+ lines of cryptic magic nobody understood. Just copy-paste from StackOverflow and pray. NPM brought its own drama: the left-pad incident that broke the internet, package hijacking, security vulnerabilities 15 levels deep in dependencies you didn’t know existed. 💀
And the tooling fatigue! Every 6 months a new bundler claiming to be “10x faster”: Webpack → Parcel → Snowpack → Vite → Turbopack → ??? Even CSS got complicated: plain files → Sass/Less → CSS Modules → CSS-in-JS → Tailwind. Styling became a build step!
The framework wars didn’t help: React vs Vue vs Angular vs Svelte. Each with their own ecosystem, build tools, and “best practices” that changed yearly. Exhausting! 😮💨
5. Node.js Enters the Chat
Then came Node.js. Ryan Dahl took Chrome’s V8 engine and brought JavaScript to the server. The pitch was seductive: “Write JavaScript everywhere! Share code between frontend and backend! One language for everything”
Express.js made it simple to build APIs. Later came Fastify for performance, NestJS for structure (heavily inspired by Angular), and countless other frameworks. The ecosystem exploded. NPM became the largest package registry in the world. JavaScript was no longer just a browser language. It was a legitimate backend contender.
For me as a Laravel and PHP developer, this created an interesting dilemma. Do we keep Laravel & PHP for the backend while still benefiting from the JS ecosystem for the frontend? That’s what I have done for a few years till now and still do. We have clear boundaries between the two, and we can still benefit from the JS ecosystem for the frontend.
But we also get the overhead of managing two different ecosystems, two different languages, two different “build” tools, and so on. Not very bueno!
On the other side, the promise of “JavaScript everywhere” was appealing, but it came with trade-offs. Node’s async/callback hell (before async/await), different paradigms, and the lack of Laravel’s batteries-included approach meant you were building everything from scratch or stitching together dozens of packages. This is where the complexity really starts to kick in.
5.1 The SSR Complexity
With Node.js came the possibility of Server-Side Rendering (SSR). Remember how simple PHP was? Write your template, render on the server, send HTML to the browser. Done.
SSR in JavaScript? Not so simple. Now you need to render React/Vue on the server, send HTML to the browser, then “hydrate” it on the client so it becomes interactive. Sounds easy? Well, you need to handle:
- Server and client builds (two separate bundles)
- Hydration mismatches (server HTML doesn’t match client)
window is not definederrors everywhere- Memory leaks on the server
- Data fetching on both server and client
Meta-frameworks like Next.js and Nuxt eventually made this easier, but the complexity is still there under the hood. What used to be simple server rendering in PHP became a complex dance of server/client coordination. We gained interactivity but lost simplicity. 😅
6. Laravel Inertia JS - Bridging the Gap
Laravel wasn’t going to sit still while the JavaScript world took over. Inertia.js by Jonathan Reinink is a clever solution that lets you build modern single-page applications using classic server-side routing and controllers. No need to build a separate API. No REST endpoints. No GraphQL schemas. 🎯
Inertia acts as the glue between your Laravel backend and your React, Vue, or Svelte frontend. You return data from your controllers, and Inertia handles the client-side rendering. It feels like traditional server-side rendering but with the interactivity of an SPA. You get the best of both worlds: Laravel’s elegant backend with modern JavaScript frameworks on the frontend.
For many Laravel developers, this is the sweet spot. You could keep your PHP expertise, maintain your existing Laravel patterns, and still deliver the modern UX that clients demanded. No need to rewrite everything in Node.js. No need to maintain two separate applications. Just Laravel doing what it does best, with JavaScript sprinkled on top where it matters.
I’m a BIG fan of InertiaJS, being one of the early adopters of the framework, contributing to the community and helping others to get started with it. If you have any questions about InertiaJS, feel free to join our Discord Server and ask away! ❤️
Here’s a quick example of how Inertia works:
// Laravel Controller
class UserController extends Controller
{
public function show(User $user)
{
return Inertia::render('Users/Show', [
'user' => $user,
'posts' => $user->posts()->latest()->get(),
]);
}
}
// React Component (Users/Show.tsx)
import { Head } from '@inertiajs/react'
// Option 1: No types 😢
interface Props {
user: any
posts: any[]
}
// Option 2: Manual types - but you need to keep them in sync! 🔄
interface User {
id: number
name: string
email: string
}
interface Post {
id: number
title: string
content: string
}
interface PropsTyped {
user: User
posts: Post[]
}
export default function Show({ user, posts }: PropsTyped) {
return (
<>
<Head title={user.name} />
<h1>{user.name}</h1>
<p>{user.email}</p>
<div>
{posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
</div>
))}
</div>
</>
)
}
Simple and elegant! But notice the problem? You can write your own types manually, but now you need to keep them in sync with your backend. Add a field in your Laravel model? You need to remember to update the TypeScript interface. Rename a property? Better not forget to change it in both places! This manual sync is error-prone and tedious. 😓
But how do we keep things typesafe automatically? How do we close the gap between our PHP Backend and our Javascript frontend STILL being typesafe? Let’s jump into typescript section for a while and come back to this topic later.
7. TypeScript - JavaScript That Scales
In 2012, Microsoft released TypeScript, led by Anders Hejlsberg (the creator of C#). The pitch? JavaScript with optional static typing. At first, many developers were skeptical. “Why would I need types in JavaScript? It’s supposed to be flexible!”
But as JavaScript applications grew larger and more complex, the cracks started to show. Refactoring was terrifying. You’d rename a property and hope you caught all the references. Runtime errors that could’ve been caught at compile time. No autocomplete or IntelliSense worth mentioning. The developer experience was rough for large codebases.
TypeScript changed that. Suddenly you had type safety, interfaces, generics, and incredible tooling. Your IDE could catch errors before you even ran the code. Refactoring became safe. Large teams could work on the same codebase without stepping on each other’s toes.
The adoption was gradual but steady. Angular 2+ went all-in on TypeScript. React added first-class support. Vue 3 was rewritten in TypeScript. Next.js, Remix, NestJS. All TypeScript by default. Even Node.js now has built-in TypeScript support with type stripping.
Today, TypeScript isn’t just popular, it’s the default. Most new JavaScript projects start with TypeScript. It’s become what JavaScript should have been from the start: a language that scales from small scripts to massive applications. For me coming from typed languages like PHP (with its type hints), TypeScript feels like coming home.
But it’s not perfect. Typescript also brings additional complexity to the table with false sense of security and type safety, while bloating part of your codebase with types everywhere :p
8. Full Stack Laravel + Typescript = The perfect combo?
While working with Inertia, I felt the need to also have my frontend fully typesafe. So when I changed a DTO or a Request/Response, it would reflect on the frontend that the property was not there anymore or it was changed. How cool is that for developers?
This is where Laravel Data by Spatie comes in. It pairs perfect with Spatie Typescript Transformer, so everytime you change your Data Object it would generate a typescript file with all your definitions and types for your data objects that you can use in your frontend! Yay! We got it right? Yeah partially!
Here is an example of a Data Object in Laravel → Laravel Data → Typescript Transformer:
<?php
namespace App\Data;
use Closure;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Lazy;
/**
* The data shared from the server to the client
* when the user is logged in, includes wallet,
* notifications, and everything about the user
*/
final class UserAuthenticatedData extends Data
{
public function __construct(
public readonly ?UserData $user,
public readonly ?WalletData $wallet,
public readonly Lazy|int|Closure $notifications_count = 0,
) {}
}
// UserAuthenticatedData.ts
export interface UserAuthenticatedData {
user: UserData | null;
wallet: WalletData | null;
notifications_count: number | Lazy<number> | Closure;
}
// vite.config.ts
import laravel from 'laravel-vite-plugin';
import { run } from 'vite-plugin-run'
export default defineConfig(({ isSsrBuild }) => {
return {
plugins: [
laravel({
input: [
'resources/application/main.tsx',
'resources/application/css/app.css'
],
ssr: 'resources/application/ssr.tsx',
refresh: true,
}),
run({
silent: true,
input: [
{
name: 'Generate TypeScript Types',
run: ['php', 'artisan', 'typescript:transform'],
pattern: [
'app/**/*Data.php',
'app/**/Enums/**/*.php',
'app/**/Http/Requests/**/*Request.php',
'app/**/Http/Responses/**/*Response.php',
'app/**/Http/Views/**/*View.php',
],
onFileChanged: () => console.info('[Generators] Generating TypeScript Types 🎨')
},
]
})
],
}
});
But this also comes at a cost. While you could use the generated types, you will also be required to create a Laravel Data DTO or a form request for EVERY piece that you send to the frontend. This could quickly become a PITA = Overhead! Also it’s not out-of-the-box. You would still need to setup all the packages, transforms, get vite dev server to work, etc.
But at least, we got types and the best of both worlds! We have closed the gap between our PHP Backend and our Javascript frontend and we can still benefit from the JS ecosystem for the frontend. But we STILL not there yet in terms of developer experience!
There are also a few issues with this approach:
- We get only types, what if we want to Zod Schemas or Standard Schemas? humm
- What if we need to derive certain types? Omit, Pick, etc? We would still need to write types on the frontend.
- How about Generics? We can’t use them in PHP because we don’t really have generics. So we would need to type the generics on the DTOS with some weird string syntax that is not typesafe :(
Well we got to a point here. While it’s not a perfect solution, for me it’s a good sweet spot if you wanna keep yourself in the Laravel ecosystem and still benefit from the JS ecosystem for the frontend. I would love to see an official “solution” from Laravel to make this even better. Let’s hope so!
9. FullStack Typescript
Well, I have tried a ton of JS/TS full stack approaches in the past 3 years in attempt to mimic our good beloved Laravel. While it’s not possible to fully mimic it, I have found a few approaches that are very close to Laravel in terms of DX and developer experience.
While it’s easy to fall into the hype train of certain frameworks and tools specially if you are active on X (twitter), I have tried to stay away from it and focus on trying out myself.
Here is what I have tried so far and what I have found:
- Next.js: The #1 Framework for React, Huge Ecosystem, great docs, avg DX. But Next lost me with all the unnecessary complexity with the RSC approach, feels like a step back from the “old” React. Also a bit of vendor lock-in with Vercel, while some might say otherwise, that’s a fact.
- Hono/Elysia: Both are GREAT! You can build a really nice API in no time, fully typed, OpenAPI spec compliant, Swagger Docs ready for you, it’s fast, and it’s simple yet powerful.
- Tanstack Start: The goat of the frameworks for me, super flexible, fully typed end to end, doesn’t get in your way, easy mental model, great community & fully open-source to the core.
- Vue/Nuxt: Pretty amazing also, I would honestly pick Vue/Nuxt any day over React if the ecosystem was the same. Here I said it :p
So my current sweet spot is Tanstack Start + Elysia for the API with RPC calls to the backend. This way we can have the best of both worlds, fully typed end to end. You still have an API if you need it, still can call it from the frontend, full SSR without any overhead, streaming, server actions, one single language, one single codebase in a monorepo using Turborepo, etc.
Here’s a quick example of how beautiful the DX is with Elysia + Treaty RPC:
// Backend API with Elysia
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/users/:id', async ({ params }) => {
const user = await db.user.findUnique({
where: { id: params.id }
})
return { user }
}, {
params: t.Object({
id: t.String()
})
})
.post('/users', async ({ body }) => {
const user = await db.user.create({ data: body })
return { user }
}, {
body: t.Object({
name: t.String(),
email: t.String()
})
})
.listen(3000)
export type App = typeof app
// Frontend with Treaty RPC in Tanstack Router - Fully typed!
import { createFileRoute } from '@tanstack/react-router'
import { treaty } from '@elysiajs/eden'
import type { App } from './server'
const api = treaty<App>('localhost:3000')
export const Route = createFileRoute('/users/$id')({
beforeLoad: async ({ params }) => {
// Autocomplete and type safety everywhere! 🎉
const { data } = await api.users({ id: params.id }).get()
// ^? { user: User }
return { user: data.user }
},
component: UserProfile
})
function UserProfile() {
const { user } = Route.useLoaderData()
// ^? User - fully typed!
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
This is what I mean by fully typed end to end. Change the backend, and your frontend immediately knows about it. No code generation, no build steps, just pure TypeScript magic! The data flows from your API through your router loader directly into your components, all with perfect type safety.
What do I miss here from Laravel? Queues we got to install BullMQ or Trigger.dev, Auth with better-auth, etc. So you need to glue different pieces together to get the same DX, but once your “boilerplate” is done, I would say it’s a tie!
10. PHP ( Laravel ) vs Javascript/Typescript
This is a tough one, because both ecosystems are great and I can’t really stand with one side or another. Laravel ecosystem is perfect, and while smaller vs Javascript, this is not a problem if you see it from a different perspective. Because it’s not fragmented like Javascript, you will get first party support for most of the things you need but you will also miss some things or get a few things “late” compared to Javascript.
I’ll keep this short and give my honest opinion on both ecosystems: ⚖️
10.1 PHP ( Laravel ) Pros ✅
- Batteries included: Queues, Events, Jobs, Notifications, ORMs, Auth, Mail etc
- Top-tier Documentation
- Amazing DX & tooling: Telescope, Horizon, Forge, Vapor, etc
- Scales pretty well with large projects
- PHP is mature and very organized in terms of code structure and best practices.
- Dependency Injection is a first class citizen, you can easily inject dependencies into your controllers, services, etc
- If you embrace the framework and its patterns, you will be able to build very maintainable and scalable applications, buttery smooth!
- Eloquent is THE ORM!
10.2 PHP ( Laravel ) Cons ❌
- It’s still PHP, so you still get that gap if you wanna use Javascript for the frontend.
- Things tend to come a bit “late” compared to Javascript, say AI tools, etc. Example: Prism is amazing but it’s not yet comparable to Vercel AI SDK for example.
- Serverless is not as mature as Javascript, but it’s getting there
- Local Env is still meh, you need to rely on Herd, Brew to install PHP, nginx, fpm etc. You could also go with
php artisan serve, but yeah, you get the point here. - Smaller community means also less packages, etc.
- No generics in PHP, we need to rely on PHPStan to get that perfect static analysis and type safety.
- While you can, if you go out of the “laravel-way” you will be a bit on your own. If that’s the case use Symfony, there I said it.
10.3 Javascript/Typescript Pros ✅
- Huge Ecosystem: Millions of packages, frameworks, tools, libraries, etc.
- AI tools are first party in Javascript + Python, so we get them first here
- Universal, with correct shared code and setup, you can share parts of your codebase between frontend and backend. Example types, little utilities, etc. This is a huge advantage!
- Deployment + Serverless is more mature than Laravel, you can deploy to Vercel, Netlify, AWS, GCP, etc. First party support for most of the platforms, unlike PHP :(
- One file is enough to start with, example with Bun + index.ts you’re ready to go!
- Fully typed end to end with typescript, zod, standard schemas, etc.
- HMR is really nice, get instant feedback while you change a file.
- SSR, Sockets, Streaming out of the box
- More jobs and higher salaries (could be wrong here but that’s what we see so far)
10.4 Javascript/Typescript Cons ❌
- Fragmented Ecosystem: Hard to pick a winner sometimes, lots of options also makes it harder to choose and learn.
- New boy in the block is common, new framework pops up and then a year later it’s gone, risky takes.
- Composability is a double edged sword, you can build very modular and reusable code, but you can also build very complex and hard to maintain code if you don’t do it right.
- Harder to do Dependency Injection, tools like Effect will probably solve this, but still babies in the scene :p
- NPM is great but the latest supply chain attacks and vulnerabilities are a bit of a pain, so we need to be careful with what we install and what we use.
- Multiple runtimes, you can use Bun, Node.js, Deno, but also pitfalls when using one over the other.
- Error Handling & Stack traces are a pain compared to PHP.
11. Conclusion
Like many said, we should avoid framework dramas. At the end of the day being an engineer, you should be able to pick the right tool for the job.
If I would still need a quick app or dashboard I would probably pick Laravel + Filament because it gets the job done fast and easy. But if I would need a more complex application with fancy UI & realtime capabilities I would probably pick Tanstack Start + Elysia.
I really hope PHP will get more “modern” features like Generics and gets closer to the Javascript DX. Also I would love if the Javascript ecosystem would focus on being more mature and laravel-like for a defacto standard (I see you AdonisJS!) 👀
At the end, both ecosystems are amazing, and we should be grateful to have such great tools at our disposal. Keep learning, keep building, and most importantly, keep having fun!
12. Credits & Links
- Laravel
- Inertia.js
- Laravel Data
- Spatie Typescript Transformer
- Filament
- PHPStan
- Telescope
- Horizon
- Forge
- Vapor
- Eloquent
- Herd
- jQuery
- Bootstrap
- React
- Vue
- Angular
- Gulp
- Grunt
- Webpack
- Rollup
- Parcel
- Vite
- Babel
- Tailwind
- TypeScript
- Node.js
- Express.js
- Fastify
- NestJS
- Next.js
- Nuxt
- Remix
- Bun
- Deno
- Tanstack Start
- Elysia
- Hono
- Zod
- Standard Schemas
- BullMQ
- Trigger.dev
- better-auth
- Effect
- Vercel AI SDK
- Prism
- Vercel
- Netlify
- AdonisJS
- Turborepo
- InertiaJS Discord
- Laracasts
- TanStack Discord