Introduction

My previous posts focused on running Blazor apps in a Linux environment where you had administrative rights, including the ability to update the Apache configuration and to use systemctl to run Blazor server components. Running Blazor apps in a shared host environment is somewhat more challenging, as you do not have this ability to directly host your server. Many shared hosting environments, however, do support PHP for server-side scripting. This post will show how Blazor apps can work with PHP on the server.

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 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.

A Connected App

Blazor Standalone WebAssembly apps do not need a dotnet server component to work.

Create a Blazor WebAssembly Standalone app called PHPTest.

  • Use Authentication Type – none,
  • Include sample pages.

Apply the same base URL fix as in previous posts:

  • 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".

Deploy to the Linux server by copying the contents of …\publish\wwwroot to the application root folder on the server. If you have set up a server such as I have for my previous posts, this could be to /var/www/html/apps/PHPTest.

The Weather Sample Page

The sample page Pages\Weather.razor demonstrates the display of tabular data. The file wwwroot\sample-data\weather.json is processed to create an array of objects which are rendered in a simple loop.

The weather.json file is actually loaded from the server, using the GetFromJsonAsync function:

Razor
@page "/weather"
@inject HttpClient Http

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates fetching data from the server.</p>

...

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
    }

    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);
    }
}

The action can be seen in the Apache log:

... "GET /PHPTest/sample-data/weather.json HTTP/1.1" 200 501 ...

This may be contrasted with the operation of Components\Pages\Weather.razor from the Blazor Web App template, which doesn’t fetch data from the server at all – it generates random data locally in the client and uses a timed delay to make it appear as if it is fetching data from the server.

We could create a PHP page wwwroot\sample-data\weather.php to return the same results for the standalone app:

PHP
<?php

// data to return
$data = [
	[
        "date" => "2022-01-06",
        "temperatureC" => 1,
        "summary" => "Freezing"
	],
	[
        "date" => "2022-01-07",
        "temperatureC" => 14,
        "summary" => "Bracing"
	],
	[
        "date" => "2022-01-08",
        "temperatureC" => -13,
        "summary" => "Freezing"
	],
	[
        "date" => "2022-01-09",
        "temperatureC" => -16,
        "summary" => "Balmy"
	],
	[
        "date" => "2022-01-10",
        "temperatureC" => -2,
        "summary" => "Chilly"
	]
];

// set the content type
header("Content-Type: application/json");

// return as JSON
echo json_encode($data);

?>

Modifying Pages\Weather.razor to fetch this file accomplishes the same result when the app is published to Linux:

Razor
@page "/weather"
@inject HttpClient Http

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates fetching data from the server.</p>

...

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.php");
    }

    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);
    }
}

Now the HTTP GET request executes the PHP code in weather.php to generate the JSON response. It could even access another service on the server to obtain live sensor data.

Unfortunately, this does not work well in the development environment, as Visual Studio does not have native support for PHP. You could install a VS plugin such as DevSense to add such support. We will show an alternative approach shortly.

The Counter Sample Page

The sample page Pages\Counter.razor is quite simple. It merely updates a local counter when a button is pressed. It does not even maintain the state of the counter across pages.

This last weakness is easily corrected through the use of a non-transient service within the app. It might be more interesting, however, to solve this using PHP on the server.

Create a server page wwwroot\counter.php:

PHP
<?php

session_start();

$_SESSION["action"] = "none";

// initialize counter if necessary
if (!isset($_SESSION["count"])) {
	$_SESSION["count"] = 0;
	$_SESSION["action"] = "initialize";
}

if ($_GET["action"] == "increment") {
	$_SESSION["count"] = $_SESSION["count"] + 1;
	$_SESSION["action"] = "increment";
}

if ($_GET["action"] == "decrement") {
	$_SESSION["count"] = $_SESSION["count"] - 1;
	$_SESSION["action"] = "decrement";
}

// package the state
$result = [
	"count" => $_SESSION["count"],
	"action" => $_SESSION["action"]
];

// set the content type
header("Content-Type: application/json");

// return as JSON
echo json_encode($result);

?>

Modify Pages\Counter.razor to use this page:

Razor
@page "/counter"
@inject HttpClient Http

<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 CounterData? data;
    private int currentCount;
    private string lastAction = "unknown";

    protected override async Task OnInitializedAsync()
    {
        data = await Http.GetFromJsonAsync<CounterData>("counter.php");
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "unknown";
    }

    private async void IncrementCount()
    {
        var response = await Http.PostAsync("counter.php?action=increment", null);
        data = await response.Content.ReadFromJsonAsync<CounterData>();
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "failed to increment";
        await InvokeAsync(StateHasChanged);
    }

    private async void DecrementCount()
    {
        var response = await Http.PostAsync("counter.php?action=decrement", null);
        data = await response.Content.ReadFromJsonAsync<CounterData>();
        currentCount = data?.Count ?? -1;
        lastAction = data?.Action ?? "failed to decrement";
        await InvokeAsync(StateHasChanged);
    }

    public class CounterData
    {
        public int Count { get; set; }
        public string? Action { get; set; }
    }
}

Deploying this to Linux will provide an improved Counter page.

This example demonstrates how to use the query string to indicate to the server which action is requested. It also saves the current count state across requests, so that when switching pages within the app the count is preserved.

We could build a more complex application following this approach, with each requirement for server-based resources being satisfied by a separate PHP page and appropriate linkage. These pages could even have shared state through PHP session data. This has the advantage of simplicity but lacks cohesion – shared PHP state could lead to inadvertent overlaps and the core communication logic is replicated widely within the app.

Centralized Routing

A consolidated request service centralizes the details of the server calls so that API changes can be made in one place. It also allows for a different mechanism to be used in the development environment where PHP is not supported. We can use Blazor service injection to provide this flexibility.

Create the folder Services at the project root, and add it to _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 Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using PHPTest
@using PHPTest.Layout
@using PHPTest.Services

Create Services\ServerRequestService.cs:

C#
namespace PHPTest.Services
{
    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);
    }

    public class CounterData
    {
        public int Count { get; set; }
        public string? Action { get; set; }
    }

    public abstract class ServerRequestService
    {
        public abstract Task<WeatherForecast[]?> GetWeatherForecasts();
        public abstract Task<CounterData?> GetCounterData();
        public abstract Task<CounterData?> IncrementCounter();
        public abstract Task<CounterData?> DecrementCounter();
    }
}

Create concrete implementation Services\PHPRequestService.cs:

C#
using System.Net.Http.Json;

namespace PHPTest.Services
{
    public class PHPRequestService(HttpClient httpClient) : ServerRequestService
    {
        readonly HttpClient client = httpClient;

        public async override Task<WeatherForecast[]?> GetWeatherForecasts()
        {
            return await client.GetFromJsonAsync<WeatherForecast[]>("boundary.php?group=weather");
        }

        public async override Task<CounterData?> GetCounterData()
        {
            return await client.GetFromJsonAsync<CounterData>("boundary.php?group=counter");
        }

        public async override Task<CounterData?> IncrementCounter()
        {
            var response = await client.PostAsync("boundary.php?group=counter&action=increment", null);
            return await response.Content.ReadFromJsonAsync<CounterData>();
        }

        public async override Task<CounterData?> DecrementCounter()
        {
            var response = await client.PostAsync("boundary.php?group=counter&action=decrement", null);
            return await response.Content.ReadFromJsonAsync<CounterData>();
        }
    }
}

This version of the server request service will direct requests to a server-based PHP page.

Create concrete implementation Services\DebugRequestService.cs:

C#
using System.Net.Http.Json;

namespace PHPTest.Services
{
    public class DebugRequestService(HttpClient httpClient) : ServerRequestService
    {
        readonly HttpClient client = httpClient;

        public override async Task<WeatherForecast[]?> GetWeatherForecasts()
        {
            return await client.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
        }

        private int counter = 0;

        public override Task<CounterData?> GetCounterData()
        {
            CounterData data = new()
            {
                Count = counter,
                Action = "none"
            };
            return Task.FromResult((CounterData?)data);
        }

        public override Task<CounterData?> IncrementCounter()
        {
            CounterData data = new()
            {
                Count = ++counter,
                Action = "increment"
            };
            return Task.FromResult((CounterData?)data);
        }

        public override Task<CounterData?> DecrementCounter()
        {
            CounterData data = new()
            {
                Count = --counter,
                Action = "decrement"
            };
            return Task.FromResult((CounterData?)data);
        }
    }
}

This version of the server request service will provide functionality similar to what the original app did.

Register the appropriate service in Program.cs based on the execution environment:

C#
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PHPTest.Services;

namespace PHPTest
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");
            builder.RootComponents.Add<HeadOutlet>("head::after");

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

            if (builder.HostEnvironment.IsDevelopment())
            {
                builder.Services.AddScoped<ServerRequestService, DebugRequestService>();
            }
            else
            {
                builder.Services.AddScoped<ServerRequestService, PHPRequestService>();
            }

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

Modify Pages\Weather.razor to use the new service:

Razor
@page "/weather"
@inject HttpClient Http
@inject ServerRequestService ServerRequest

<PageTitle>Weather</PageTitle>

...

@code {
    private WeatherForecast[]? forecasts;

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

Modify Pages\Counter.razor to use the new service:

Razor
@page "/counter"
@inject HttpClient Http
@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 CounterData? data;
    private int currentCount;
    private string lastAction = "unknown";

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

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

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

A consolidated PHP page for satisfying requests has the advantage of putting the API logic in one place and can be developed in sync with the request service it is intended to respond to.

Create a combined service page wwwroot\boundary.php:

PHP
<?php

session_start();

if ($_GET["group"] == "counter") {

	$_SESSION["action"] = "none";

	if (!isset($_SESSION["count"])) {
		$_SESSION["count"] = 0;
		$_SESSION["group"] = "counter";
		$_SESSION["action"] = "initialize";
	}

	if ($_GET["action"] == "increment") {
		$_SESSION["count"] = $_SESSION["count"] + 1;
		$_SESSION["action"] = "increment";
	}

	if ($_GET["action"] == "decrement") {
		$_SESSION["count"] = $_SESSION["count"] - 1;
		$_SESSION["action"] = "decrement";
	}

	$result = [
		"count" => $_SESSION["count"],
		"action" => $_SESSION["action"]
	];

	header("Content-Type: application/json");

	echo json_encode($result);

} elseif ($_GET["group"] == "weather") {
	
	$result = [
		[
			"date" => "2022-01-06",
			"temperatureC" => 1,
			"summary" => "Freezing"
		],
		[
			"date" => "2022-01-07",
			"temperatureC" => 14,
			"summary" => "Bracing"
		],
		[
			"date" => "2022-01-08",
			"temperatureC" => -13,
			"summary" => "Freezing"
		],
		[
			"date" => "2022-01-09",
			"temperatureC" => -16,
			"summary" => "Balmy"
		],
		[
			"date" => "2022-01-10",
			"temperatureC" => -2,
			"summary" => "Chilly"
		]
	];

	header("Content-Type: application/json");

	echo json_encode($result);

} else {
	http_response_code(400);
}

?>

The app should now run in both the debugging environment and when deployed to the Linux server.

The boundary.php page above could be improved by using PHP classes to mirror the WeatherForecast and CounterData classes in the C# code.

Summary

The solution above shows how a Blazor standalone app can communicate with the server using PHP. This approach has a number of weaknesses:

  • There is no consideration for application security.
  • PHP uses a similar programming language to C# but there are important differences.
  • Even if matching concepts such as classes are used in the PHP and C# components, there is no mechanism for detecting mismatches at design time.

If you wish to pursue PHP for server-side support you may consider a more comprehensive package such as Laravel or Symfony.

My next post will explore improving on some of the weaknesses noted by combining PHP with C# for server-side coding.

Categories: Development

0 Comments

Leave a Reply

Avatar placeholder

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