Passing Data from Server to Client

Work in progress

This page is a work in progress. Help complete it by contributing on GitHub!

Modern web applications need to pass data from the backend (PHP) to the frontend (JavaScript/Vue). UserFrosting provides several strategies for this, depending on your needs.

This page covers the main approaches: global configuration objects, page-specific data, component props, and data attributes. Choose the right approach based on your use case.

Understanding the Client-Server Boundary

Before we dive in, remember that JavaScript cannot directly access PHP variables. When your browser loads a page:

  1. Server renders: PHP/Twig generates HTML with embedded data
  2. Client receives: Browser gets a static HTML document
  3. JavaScript runs: Your code accesses the embedded data

Any data you need on the client must be included in the HTML response. Changes in JavaScript don't affect PHP variables unless you make an API request.

Note

This is a fundamental limitation of web architecture, not a UserFrosting quirk. The server and client are separate programs running on different machines.

Approaches to Passing Data

1. Global Configuration Object (Best for Site-Wide Settings)

UserFrosting creates a global site JavaScript object on every page containing configuration values. This is ideal for settings you'll need across multiple pages.

Available in: window.site or just site

Common values:

  • site.uri.public - Base URL of your site (e.g., https://example.com)
  • site.debug.ajax - Whether AJAX debugging is enabled
  • site.csrf.name - CSRF token name
  • site.csrf.value - CSRF token value

Example usage in TypeScript:

// Making an API request with base URL
import axios from 'axios'

const response = await axios.get(`${site.uri.public}/api/users`)

// Including CSRF token in POST requests
await axios.post(`${site.uri.public}/api/users`, userData, {
  headers: {
    [site.csrf.keys.name]: site.csrf.name,
    [site.csrf.keys.value]: site.csrf.value
  }
})

How it works: The site object is generated by core/templates/pages/partials/config.js.twig and pulls values from your configuration files.

Customizing: To add custom values to site:

  1. Option A: Extend your sprinkle's configuration:

    // app/sprinkles/mysprinkle/config/default.php
    return [
        'site' => [
            'myapp' => [
                'feature_flag' => true,
                'api_version' => 'v2'
            ]
        ]
    ];
    
  2. Option B: Override config.js.twig in your sprinkle to add custom logic

Warning

Everything in site is visible in the page source! Never put sensitive information (API keys, passwords, private user data) in this object.

2. Page-Specific Data (Best for Page Initialization)

For data specific to a single page, use the page global object. This is populated by passing data to the page key when rendering your Twig template.

PHP Controller:

public function displayUsers(Request $request, Response $response): Response
{
    return $this->view->render($response, 'pages/users.html.twig', [
        'page' => [
            'api_endpoint' => '/api/users',
            'per_page' => 25,
            'initial_data' => $this->userRepository->all()
        ]
    ]);
}

Twig Template (include in your page):

{% block scripts_page %}
    <script>
        {% include "pages/partials/page.js.twig" %}
    </script>
{% endblock %}

JavaScript/TypeScript Access:

// The page object is now available globally
console.log(page.api_endpoint) // "/api/users"
console.log(page.per_page) // 25

// Use it in your Vue components or vanilla JS
const users = page.initial_data

This approach works well for validation rules, API endpoints, initial data loads, and feature flags specific to a page.

3. Vue Component Props (Best for Component Data)

When using Vue 3 components, pass data directly as props through the component's mounting HTML.

Twig Template:

<div id="user-profile"
     data-user-id="{{ user.id }}"
     data-username="{{ user.username }}"
     data-email="{{ user.email }}">
</div>

Vue Component (UserProfile.vue):

<template>
  <div>
    <h2>{{ username }}</h2>
    <p>Email: {{ email }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const userId = ref<number>(0)
const username = ref<string>('')
const email = ref<string>('')

onMounted(() => {
  const el = document.getElementById('user-profile')
  if (el) {
    userId.value = parseInt(el.dataset.userId || '0')
    username.value = el.dataset.username || ''
    email.value = el.dataset.email || ''
  }
})
</script>

Or, better yet, pass props when creating the Vue app:

Twig Template:

<div id="user-profile"></div>

<script>
    window.userProfileData = {
        userId: {{ user.id }},
        username: "{{ user.username }}",
        email: "{{ user.email }}"
    };
</script>

TypeScript:

import { createApp } from 'vue'
import UserProfile from './components/UserProfile.vue'

createApp(UserProfile, {
  userId: window.userProfileData.userId,
  username: window.userProfileData.username,
  email: window.userProfileData.email
}).mount('#user-profile')

4. Data Attributes (Best for Small Amounts of Data)

For simple, element-specific data, use HTML5 data-* attributes. This is ideal when your JavaScript needs to know about specific elements on the page.

Twig Template:

<button
    class="delete-user"
    data-user-id="{{ user.id }}"
    data-user-name="{{ user.username }}">
    Delete User
</button>

TypeScript:

document.querySelectorAll('.delete-user').forEach(button => {
  button.addEventListener('click', async (e) => {
    const target = e.currentTarget as HTMLButtonElement
    const userId = target.dataset.userId
    const userName = target.dataset.userName

    if (confirm(`Delete user ${userName}?`)) {
      await deleteUser(userId)
    }
  })
})

Vue 3 Alternative (using template refs):

<template>
  <button @click="handleDelete" :data-user-id="userId">
    Delete User
  </button>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  userId: number
  userName: string
}>()

const handleDelete = async () => {
  if (confirm(`Delete user ${props.userName}?`)) {
    await deleteUser(props.userId)
  }
}
</script>

5. API Requests (Best for Dynamic Data)

For data that changes frequently or is too large to embed, fetch it via API calls after the page loads.

TypeScript:

import axios from 'axios'

interface User {
  id: number
  username: string
  email: string
}

// Fetch data after page load
async function loadUsers(): Promise<User[]> {
  const response = await axios.get<User[]>(`${site.uri.public}/api/users`)
  return response.data
}

// Use in your code
const users = await loadUsers()

Vue 3 Composable (reusable logic):

// composables/useUsers.ts
import { ref, Ref } from 'vue'
import axios from 'axios'

export function useUsers() {
  const users: Ref<User[]> = ref([])
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function fetchUsers() {
    loading.value = true
    error.value = null

    try {
      const response = await axios.get(`${site.uri.public}/api/users`)
      users.value = response.data
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return { users, loading, error, fetchUsers }
}

Choosing the Right Approach

Use this decision tree:

Data Type Best Approach Why
Site-wide config (URLs, CSRF) Global site object Available everywhere, cached
Page-specific settings Global page object Page scope, easy access
Component initialization Vue props Type-safe, reactive
Element metadata Data attributes Semantic, standard HTML
Large/dynamic data API requests Reduces initial page size
Real-time data API + polling/WebSockets Always current

Modern ES Modules and TypeScript

With Vite and modern JavaScript, avoid global variables when possible. Instead, export and import values:

config.ts:

// Re-export site config with types
export interface SiteConfig {
  uri: {
    public: string
  }
  csrf: {
    name: string
    value: string
    keys: {
      name: string
      value: string
    }
  }
}

// Access global site object with type safety
export const siteConfig: SiteConfig = (window as any).site

Usage:

import { siteConfig } from './config'

// Now fully typed!
const url = `${siteConfig.uri.public}/api/users`

Security Considerations

Warning

Never expose sensitive data to the client:

  • Database credentials
  • API keys or secrets
  • Other users' private information
  • Internal system paths
  • Unfiltered user input (XSS risk)

Safe to expose:

  • Public URLs and endpoints
  • CSRF tokens (designed for client use)
  • Current user's own data
  • Public configuration settings

Remember: Anything in the HTML can be viewed by the user. Treat all client-side data as potentially compromised.

Debugging Data Transfer

View Available Data

Open your browser's console and type:

console.log('Site config:', site)
console.log('Page data:', page)

Validate JSON Structure

If data isn't working as expected, check that your PHP array converts correctly to JSON:

// Good - simple types convert cleanly
'count' => 42,
'name' => 'John',
'active' => true

// Problematic - resource types don't serialize
'database' => $pdo, // ❌ Won't work

// Solution - extract only the data you need
'user' => [
    'id' => $user->id,
    'name' => $user->username
]

What's Next?

Now that you know how to pass data from server to client, learn how to use it in:

Tip

Start simple with global objects (site, page), then graduate to Vue props and API calls as your application grows in complexity.