Introduction

In Using PHP with Blazor I showed how to use PHP on the server to support a Blazor Web Standalone application. In this post I will show how to run a client-server Blazor Web App with PHP acting as a bridge. This approach minimizes the PHP logic necessary for supporting Blazor apps on a server where you do not have administrator rights.

Environment

My server environment has not changed since my previous posts:

  • Ubuntu 22.04.1 (LTS)
  • Apache 2.4.52 (Ubuntu)
  • PHP 8.1.2-1ubuntu2.22

The version of dotnet installed on the server is not relevant as we will not be using it for this exercise.

Source Code

The source code for this post is available on GitHub.

Client-Server Using Blazor

Our app this time will be built on top of the Blazor Web App template:

  • Project name: PHPBridge
  • Authentication type: None
  • Interactive render mode: WebAssembly
  • Interactivity location: Global

As always, modify the app to work on our server:

  • Remove the base element in wwwroot\index.html.
  • Add @page "/home" to Pages\Home.razor.
  • Modify the first NavLink element in Layout\NavMenu.razor to use href="home".

We will work in the development environment for now. I will demonstrate how to deploy and run the app on Linux a bit later on.

This demonstration will build on the Weather and Counter pages from the template, serving data from the server to the client using a simple web API.

Shared Data

We will use a shared project to hold common structures and data.

Create a new project called PHPBridge.Shared within the PHPBridge solution. This project does not need to be a Blazor project at all – a simple class library project will do. I navigated to the PHPBridge folder within the solution in order to put all of the sub-projects at the same level:

Add a Model directory to the new project. Within it, add WeatherForecast.cs:

C#
namespace PHPBridge.Shared.Model
{
    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

This should look familiar. Add Model\CounterData.cs:

C#
namespace PHPBridge.Shared.Model
{
    public class CounterData
    {
        public int Count { get; set; }
        public string? Action { get; set; }
    }
}

Now add Model\APIConstants.cs:

C#
namespace PHPBridge.Shared.Model
{
    public class APIConstants
    {
        public const string BOUNDARY = "boundary";

        public const string WEATHER = "weather";
        
        public const string COUNTER = "counter";
        public const string INCREMENT = "increment";
        public const string DECREMENT = "decrement";
    }
}

This last class provides constants that will be used on both the client and server side of the API.

The Server

The server side logic for our application is in the main project PHPBridge. We will need to add some more logic to the component to support client requests for data.

First, create a project reference to PHPBridge.Shared:

Next, create new folder Engine, and create ServerState.cs within it:

C#
namespace PHPBridge.Engine
{
    internal class ServerState
    {
        private int counter = 0;
        public int Counter
        {
            get => counter; 
            set => counter = value;
        }

        public int IncrementCounter() => ++counter;
        public int DecrementCounter() => --counter;
    }
}

This will hold the persisted value of the counter across requests, somewhat like how boundary.php in the previous post used the PHP Session to do this.

Create Engine\API.cs:

C#
using PHPBridge.Shared.Model;

namespace PHPBridge.Engine
{
    internal static class API
    {
        public static WebApplication MapAPI(this WebApplication app)
        {
            var endPoints = app.MapGroup($"/{APIConstants.BOUNDARY}");

            endPoints.MapGet(
                $"/{APIConstants.WEATHER}", 
                ()  => GetWeatherForecasts());
            endPoints.MapGet(
                $"/{APIConstants.COUNTER}", 
                (ServerState state) => GetCounterData(state));
            endPoints.MapPost(
                $"/{APIConstants.COUNTER}/{APIConstants.INCREMENT}", 
                (ServerState state) => IncrementCounter(state));
            endPoints.MapPost(
                $"/{APIConstants.COUNTER}/{APIConstants.DECREMENT}", 
                (ServerState state) => DecrementCounter(state));

            return app;
        }

        private static WeatherForecast[] GetWeatherForecasts()
        {
            return
            [
                new WeatherForecast { 
                    Date = new DateOnly(2022, 1, 6), 
                    TemperatureC = 1, 
                    Summary = "Freezing" },
                new WeatherForecast { 
                    Date = new DateOnly(2022, 1, 7), 
                    TemperatureC = 14, 
                    Summary = "Bracing" },
                new WeatherForecast { 
                    Date = new DateOnly(2022, 1, 8), 
                    TemperatureC = -13, 
                    Summary = "Freezing" },
                new WeatherForecast { 
                    Date = new DateOnly(2022, 1, 9), 
                    TemperatureC = -16, 
                    Summary = "Balmy" },
                new WeatherForecast { 
                    Date = new DateOnly(2022, 1, 10), 
                    TemperatureC = -2, 
                    Summary = "Chilly" },
            ];
        }

        private static CounterData GetCounterData(ServerState state)
        {
            return new CounterData { 
                Action = "none", 
                Count = state.Counter };
        }

        private static CounterData IncrementCounter(ServerState state)
        {
            return new CounterData { 
                Action = "increment", 
                Count = state.IncrementCounter() };
        }

        private static CounterData DecrementCounter(ServerState state)
        {
            return new CounterData { 
                Action = "decrement", 
                Count = state.DecrementCounter() };
        }
    }
}

Register the API in Program.cs in the main PHPBridge project:

C#
using PHPBridge.Components;
using PHPBridge.Engine;

namespace BlazorSecurityDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddRazorComponents()
                .AddInteractiveServerComponents();

            builder.Services.AddSingleton<ServerState>();
            
            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days...
                app.UseHsts();
            }

            app.UseHttpsRedirection();

            app.UseAntiforgery();

            app.MapStaticAssets();
            app.MapRazorComponents<App>()
                .AddInteractiveServerRenderMode();

            app.MapApi();
            
            app.Run();
        }
    }
}

The MapAPI method registers a set of endpoints that respond to various client requests:

GET /boundary/weather
Retrieve weather data.
GET /boundary/counter
Retrieve the current counter value.
POST /boundary/counter/increment
Increment the counter and return the new value.
POST /boundary/counter/decrement
Decrement the counter and return the new value.

For the counter-oriented mappings the delegates reference the registered singleton ServerState to provide access to the persistent counter.

The Client

The client side logic is in the project PHPBridge.Client. This component will act in a similar way to how the standalone app worked in the previous post.

Create a project reference to PHPBridge.Shared.

This ensures that the server and client components have matching API calls and payload layouts.

Add Services\ServerRequestService.cs:

C#
using PHPBridge.Shared.Model;
using System.Net.Http.Json;

namespace PHPBridge.Client.Services
{
    public class ServerRequestService(HttpClient httpClient)
    {
        readonly HttpClient client = httpClient;

        public async Task<WeatherForecast[]?> GetWeatherForecasts()
        {
            return await client.GetFromJsonAsync<WeatherForecast[]>(
                $"{APIConstants.BOUNDARY}/{APIConstants.WEATHER}");
        }

        public async Task<CounterData?> GetCounterData()
        {
            return await client.GetFromJsonAsync<CounterData>(
                $"{APIConstants.BOUNDARY}/{APIConstants.COUNTER}");
        }

        public async Task<CounterData?> IncrementCounter()
        {
            var response = await client.PostAsync(
                $"{APIConstants.BOUNDARY}/{APIConstants.COUNTER}/{APIConstants.INCREMENT}", 
                null);
            return await response.Content.ReadFromJsonAsync<CounterData>();
        }

        public async Task<CounterData?> DecrementCounter()
        {
            var response = await client.PostAsync(
                $"{APIConstants.BOUNDARY}/{APIConstants.COUNTER}/{APIConstants.DECREMENT}", 
                null);
            return await response.Content.ReadFromJsonAsync<CounterData>();
        }
    }
}

Unlike in the previous post, we only need one version of the service for both development and production.

Register the services needed in the client Program.cs:

C#
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PHPBridge.Client.Services;

namespace PHPBridge.Client
{
    internal class Program
    {
        static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);

            builder.Services.AddScoped(
                sp => new HttpClient { 
                    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
            builder.Services.AddScoped<ServerRequestService>();

            await builder.Build().RunAsync();
        }
    }
}

The HttpClient used by ServerRequestService is set up to use the same URI as used by the browser to fetch the home page. This URI is also used to fetch support files such as style sheets and javascript code. In this app, this means that everything is routed to the server component built from the PHPBridge project, including the GET and POST requests from ServerRequestService.

To simplify the changes to the razor pages, modify _Imports.razor:

Razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using PHPBridge.Client
@using PHPBridge.Client.Services
@using PHPBridge.Shared.Model

Modify Pages\Weather.razor:

Razor
@page "/weather"
@inject ServerRequestService ServerRequest

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    ...
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await ServerRequest.GetWeatherForecasts();
    }

}

Modify Pages\Counter.razor:

Razor
@page "/counter"
@inject ServerRequestService ServerRequest

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount, last action: @lastAction</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me to increment</button>
<button class="btn btn-primary" @onclick="DecrementCount">Click me to decrement</button>

@code {
    private int currentCount = 0;
    private CounterData? data;
    private string lastAction = "unknown";

    protected override async Task OnInitializedAsync()
    {
        data = await ServerRequest.GetCounterData();
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "unknown";
    }

    private async Task IncrementCount()
    {
        data = await ServerRequest.IncrementCounter();
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "failed to increment";
        await InvokeAsync(StateHasChanged);
    }

    private async Task DecrementCount()
    {
        data = await ServerRequest.DecrementCounter();
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "failed to decrement";
        await InvokeAsync(StateHasChanged);
    }
}

A Dry Run

If you debug the app in the development environment you should see it perform much as the app did in the previous post. You can place breakpoints in ServerRequestService in the client and API in the server to see the requests being created and responded to.

Behind the scenes the development environment includes a web server that handles the transfer of requests and data between the client and server components.

Moving to the Server

When we deployed a Blazor Web App to Linux in my first post, we created a Linux service to run the server component, then used Apache proxying to relay requests from the browser or client to this server component. We will need to accomplish these same tasks in a shared server environment.

Running the Server Component

We are assuming that the server being deployed to does not have the dotnet runtime installed. This means that we will need to deploy our app as a self-contained package. This will contain all of the dotnet support files as well as an executable file for the app itself.

The app executable will need to be started in order for it to be able to respond to client requests. One option for starting the executable is to use the PHP exec function, if it is supported on the server. This would look something like:

PHP
<?php

$address = '127.0.0.1:5000';
$command = 'nohup ./PHPBridge --urls http://$address > /dev/null 2>&1 &';
exec($command);

?>

This runs the main executable as a separate process. The output is suppressed to prevent the exec call from hanging. We could use this snippet as part of index.php in the application root, but there are some operational issues to tend with.

If the PHP exec function is not supported on the server you wish to deploy to, you will need another mechanism for ensuring that the server component is started. Many shared hosting services allow you to set up CRON jobs for maintenance. This is a possible location for such a mechanism.

The PHPBridge process, by default, will run under the same userid that PHP runs under, which on my system is www-root. It is unlikely that the app will run properly under this userid if it runs at all. As such, you will need setuid on the executable so that it runs under the owner id:

Bash
chmod u+s,a+x PHPBridge

It is also possible that the port selected is already in use, either by a previous invocation of the app or by another process. We will need a mechanism for selecting a free port. We will get to that in a moment.

If the app is already running it would be better to simply make use of it rather than launching a new instance. To do this we can have the server component create a file which we can check for in a PHP script. Add the following to Program.cs in the main PHPBridge project:

C#
using PHPBridge.Components;
using PHPBridge.Engine;

namespace BlazorSecurityDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddRazorComponents()
                .AddInteractiveServerComponents();

            builder.Services.AddSingleton<ServerState>();
            
            var app = builder.Build();

            var addr = new
            {
                URLS = app.Urls,
                Args = args,
                ConfigURLS = app.Configuration.GetValue<string>("URLS", "")
            };

            string asJSON = JsonSerializer.Serialize(addr);
            File.WriteAllText("addr.json", asJSON);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days...
                app.UseHsts();
            }

            app.UseHttpsRedirection();

            app.UseAntiforgery();

            app.MapStaticAssets();
            app.MapRazorComponents<App>()
                .AddInteractiveServerRenderMode();

            app.MapApi();
            
            app.Run();
        }
    }
}

Handling the Initial Browser Request

For the app from my first post Apache forwarded the initial GET request from the browser to the app server component, which responded with the text of the home page. We can replicate this in PHP using cURL.

Create index.php at the root of the PHPBridge project:

PHP
<?php

// see if server is already running
$addr = file_get_contents('addr.json');

if ($addr) {
    $urls = json_decode($addr, true);
    if (isset($urls['ConfigURLS'])) {
        $url = $urls['ConfigURLS'];
        if (str_contains($url, ';')) {
            $url = substr($url, 0, strpos($url,';') - 1);
        }

        $urlParts = explode(":", $url);
        $url = substr($urlParts[1], 2);
        $port = $urlParts[2];

        // check if port is open
        $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
        $result = socket_connect($socket, $url, $port);
        if (!$result) {
            unset($url);
            unset($port);
        }

        socket_close($socket);
    }
}

// start the server component if necessary
if (!isset($url)) {

    $host = '127.0.0.1';
    $startPort = 0;

    $server = stream_socket_server("tcp://$host:$startPort", $errno, $errstr);
    if ($server) {
        $address = stream_socket_get_name($server, false);
        fclose($server);

        $addrParts = explode(":", $address);
        $url = $addrParts[0];
        $port = $addrParts[1];

        $command = "nohup ./PHPBridge --urls http://$address > /dev/null 2>&1 &";
        exec($command);

        // allow server to start
        sleep(1);
    } else {
        die($errno);
    }
}

// forward headers from client
foreach (getallheaders() as $key => $value) {
    $headers[$key] = $value;
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_PORT, $port);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

$response = curl_exec($ch);

if (curl_errno($ch)) {
    die(curl_error($ch));
}

$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$httpType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);

curl_close($ch);

header("Content-Type: $httpType");
http_response_code($httpStatus);

echo $response;

?>

This file has three main parts.

Lines 3-28
Check to see if the Blazor server component is already running. If it is, get its URL.
Lines 30-53
If the Blazor server component is not running, find a free port and start the server component using that port.
Lines 55-81
Forward the current request to the server component, and return its response to the browser.

The check for a running server component depends on the existence file addr.json that is created by the component.

The second section executes if there is no server URL available, either because addr.json does not exist or it references a server that is not responding. This section makes use of the fact that most modern Linux distributions allow using port zero (‘0’) for a socket open call to indicate that the system can use any available non-system port to satisfy it. Once the new server URL is established the app is started. We introduce a slight delay to allow the app to start before sending any requests.

With a running app we can forward the original request and echo the response. The browser should receive the home page HTML in response to its GET request.

In order for this page to be usable it needs to be published to the server in a suitable format. Since the file is not contained in the wwwroot folder of the project it will not automatically be included as part of the published package. To make sure this happens set the Copy to Output Directory setting to Copy always or Copy if newer.

Furthermore, the file will need to need to be saved as a Linux text file. If you are using Windows for development, this is not the default. In Visual Studio 2026, which is still in early release, this can be done on a per-file basis. In Visual Studio 2022 you will need to fix this in two places.

In Tools->Options->Environment->Documents, check Save files with a specific encoding and select Unicode (UTF-8 without signature) – Code page 65001. This removes the BOM (Byte Order Mark) from the beginning of the file.

You will also need to change the line endings from the default CRLF that Windows uses to the LF that Linux uses. This can be changed at the bottom right of the editor window.

Click on this setting to display a pop-up menu.

Handling Subsequent Requests

After the home page is loaded the browser issues GET requests for the resources it needs, including CSS, Javascript, and Wasm files. The requests need to be forwarded to the server component.

In my first post we used ProxyPass statements in the Apache configuration to achieve this. We don’t have this option in a shared server environment, but we can get close through the use of PHP and .htaccess files.

Create .htaccess at the root of the PHPBridge project.

Apache
RewriteEngine on
RewriteRule ^([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)/?$ proxy.php?level1=$1&level2=$2&level3=$3&level4=$4&level5=$5 [L,QSA]
RewriteRule ^([^/]+)/([^/]+)/([^/]+)/([^/]+)/?$ proxy.php?level1=$1&level2=$2&level3=$3&level4=$4 [L,QSA]
RewriteRule ^([^/]+)/([^/]+)/([^/]+)/?$ proxy.php?level1=$1&level2=$2&level3=$3 [L,QSA]
RewriteRule ^([^/]+)/([^/]+)/?$ proxy.php?level1=$1&level2=$2 [L,QSA]
RewriteRule ^([^/]+.css)$ proxy.php?level1=$1 [L,QSA]

This forwards most requests to proxy.php with multiple levels in the path being converted to multiple parameters in the request. This means that, for example,

HTTP
GET /PHPBridge/lib/bootstrap/dist/css/bootstrap.min.46ein0sx1k.css

gets converted to

HTTP
GET proxy.php?level1=lib,level2=bootstrap,level3=dist,level4=css,level5=bootstrap.min.46ein0sx1k.css

Create proxy.php at the root of the PHPBridge project.

PHP
<?php

//
// takes query parameters level1-5
//

// get url from running server
$addr = file_get_contents('addr.json');

if ($addr) {
    $urls = json_decode($addr, true);
    if (isset($urls['ConfigURLS'])) {
        $url = $urls['ConfigURLS'];
        if (str_contains($url, ';')) {
            $url = substr($url, 0, strpos($url, ';') - 1);
        }

        $urlParts = explode(":", $url);
        $url = substr($urlParts[1], 2);
        $port = $urlParts[2];
    }
} else {
    die(500);
}

$path = $url;
if (isset($_GET["level1"])) {
    $path = $path . "/" . $_GET["level1"];
}
if (isset($_GET["level2"])) {
    $path = $path . "/" . $_GET["level2"];
}
if (isset($_GET["level3"])) {
    $path = $path . "/" . $_GET["level3"];
}
if (isset($_GET["level4"])) {
    $path = $path . "/" . $_GET["level4"];
}
if (isset($_GET["level5"])) {
    $path = $path . "/" . $_GET["level5"];
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $path);
curl_setopt($ch, CURLOPT_PORT, $port);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// forward headers from client
foreach (getallheaders() as $key => $value) {
    $headers[$key] = $value;
}

curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

// forward appropriate verb
switch ($_SERVER['REQUEST_METHOD']) {
    case 'POST':
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
        break;
    case 'PUT':
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
        curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
        break;
    case 'DELETE':
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
        break;
    case 'PATCH':
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
        curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
        break;
}

$response = curl_exec($ch);

if (curl_errno($ch)) {
    $errno = curl_errno($ch);
    $errmsg = curl_error($ch);
    die(curl_error($ch));
}

$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$httpType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);

curl_close($ch);

header("Content-Type: $httpType");
http_response_code($httpStatus);

echo $response;

?>

This script converts the parameters back into path segments and forwards this to the server component. The response is echoed back to the requestor. We need to ensure that the content type is correct so that the browser will correctly interpret CSS, Javascript, etc.

As with index.php, both .htaccess and proxy.php need to be published to the server, so also need the Copy to Output Directory and line ending LF set.

The resource requests from the browser for the home page all use the GET HTTP verb, but our API requests also use POST for some operations. The proxy.php script will pass on the correct verb to the server component so that the appropriate logic is invoked.

Conclusion

I have demonstrated that a client-server Blazor web app can be supported through PHP and .htaccess.

I have not addressed application security, but I have provided a way to create a client-server app entirely in C#, debug it in the development environment, and deploy it with some support files to a shared server.

Categories: Development

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *