ST_Contains(), ST_Within(), ST_Intersects(), ST_Disjoint(), ST_Touches(), ST_Overlaps(), ST_Crosses() and ST_Equals() test exact shapes and return 1 or 0; the MBR... variants test only bounding rectangles. ST_Distance_Sphere() returns meters on a sphere; ST_Distance() on a geographic SRID measures on the ellipsoid. Distance cannot use the R-tree, so filter first with a shape the index can check: a buffer around the customer.
SET @me = ST_SRID(POINT(101.6067, 3.1073), 4326);
SELECT city, ROUND(ST_Distance_Sphere(loc, @me) / 1000, 1) AS sphere_km,
ROUND(ST_Distance(loc, @me, 'kilometre'), 1) AS ellipsoid_km
FROM stores WHERE ST_Within(loc, ST_Buffer(@me, 2000000)) ORDER BY sphere_km;+---------------+-----------+--------------+ | city | sphere_km | ellipsoid_km | +---------------+-----------+--------------+ | Kuala Lumpur | 9.6 | 9.6 | | Singapore | 313.9 | 313.5 | | Kota Kinabalu | 1634.9 | 1636.3 | +---------------+-----------+--------------+
EXPLAIN FORMAT=JSON showed ["sp_loc", "index_range_scan"] for the filter. The buffer is a 32-segment polygon, so add ST_Distance_Sphere(loc, @me) <= r when the radius must be exact. The cheaper sphere was 1.4 km off over 1,635 km. On SRID 0, ST_Distance() is plain Euclidean geometry.
GeoJSON, the format browser maps consume, is always longitude first: ST_AsGeoJSON(loc) for Kota Kinabalu gave {"type": "Point", "coordinates": [116.0735, 5.9804]}, and ST_GeomFromGeoJSON() reads such text back as SRID 4326. Nest ST_AsGeoJSON(loc) in JSON_OBJECT('type', 'Feature', 'geometry', ...), aggregate with JSON_ARRAYAGG() (GROUP_CONCAT and JSON), and one query returns a whole FeatureCollection that PHP can hand to Leaflet 19,080 or OpenLayers 46,747 .